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,57 @@
1
+ import { useRef, useState } from 'react';
2
+ import { useQuery, useQueryClient } from '@tanstack/react-query';
3
+ import { ArmedButton, Badge, DetailView, FlatRow, ModelTable, Tabs, fmt, modal, toast, type Column, type FilterDef } from '../../ui';
4
+ import { useCan } from '../../client';
5
+ import { buildPhoneConfigPayload, fetchPhoneGroupChoices, lookupPhoneNumber, savePhoneConfigImperative, testPhoneConfigImperative, type PhoneSecretField, type SecretEdit } from './api';
6
+ import { exportPhoneConfigs, exportPhoneNumbers, exportSmsAudits } from './data';
7
+ import {
8
+ PHONE_CONFIG_DELETE_PERMISSIONS, PHONE_CONFIG_MANAGE_PERMISSIONS, PHONE_CONFIG_VIEW_PERMISSIONS,
9
+ PHONE_GROUP_DIRECTORY_PERMISSIONS, PHONE_LOOKUP_UI_PERMISSIONS, PHONE_NUMBER_DELETE_PERMISSIONS,
10
+ PHONE_NUMBER_VIEW_PERMISSIONS, SMS_DELETE_PERMISSIONS, SMS_VIEW_PERMISSIONS,
11
+ PhoneConfigModel, PhoneNumberModel, SmsModel, type PhoneConfigRow, type PhoneNumberRow, type SmsRow,
12
+ } from './models';
13
+
14
+ const relationLabel=(value:{id:number;name?:string;display_name?:string}|number|null,empty='System default')=>value==null?empty:typeof value==='number'?`#${value}`:value.name??value.display_name??`#${value.id}`;
15
+ const tone=(status:string)=>status==='delivered'||status==='valid'?'success':status==='failed'||status==='undelivered'||status==='invalid'?'danger':'warning';
16
+ const phoneColumns:Column<PhoneNumberRow>[]=[
17
+ {key:'phone_number',label:'Phone number',sortable:true,hideable:false,render:r=><code>{r.phone_number}</code>},
18
+ {key:'carrier',label:'Carrier',sortable:true,render:r=>r.carrier||'—'},
19
+ {key:'line_type',label:'Line type',sortable:true,render:r=>r.line_type||'—'},
20
+ {key:'is_valid',label:'Validity',render:r=><Badge tone={r.is_valid?'success':'danger'}>{r.is_valid?'valid':'invalid'}</Badge>},
21
+ {key:'lookup_count',label:'Lookups',sortable:true,align:'end'},
22
+ {key:'last_lookup_at',label:'Last lookup',sortable:true,render:r=>r.last_lookup_at?fmt.datetime(r.last_lookup_at):'Never'},
23
+ ];
24
+ const smsColumns:Column<SmsRow>[]=[
25
+ {key:'created',label:'Created',sortable:true,hideable:false,render:r=>fmt.datetime(r.created)},
26
+ {key:'direction',label:'Direction',sortable:true,render:r=><Badge tone="info">{r.direction}</Badge>},
27
+ {key:'from_number',label:'From',render:r=><code>{r.from_number}</code>},
28
+ {key:'to_number',label:'To',render:r=><code>{r.to_number}</code>},
29
+ {key:'status',label:'Status',sortable:true,render:r=><Badge tone={tone(r.status)}>{r.status}</Badge>},
30
+ {key:'provider',label:'Provider',sortable:true,render:r=>r.provider||'—'},
31
+ ];
32
+ const smsFilters:FilterDef[]=[{key:'direction',label:'Direction',type:'select',options:['inbound','outbound'].map(value=>({value,label:value}))},{key:'status',label:'Status',type:'select',options:['queued','sending','sent','delivered','failed','undelivered','received'].map(value=>({value,label:value}))},{key:'provider',label:'Provider',type:'select',options:['twilio','aws','mojo'].map(value=>({value,label:value}))},{key:'created',label:'Created',type:'daterange'}];
33
+ const configColumns:Column<PhoneConfigRow>[]=[
34
+ {key:'name',label:'Configuration',sortable:true,hideable:false},
35
+ {key:'group',label:'Scope',render:r=>relationLabel(r.group)},
36
+ {key:'provider',label:'Stored/tested provider',sortable:true,render:r=><Badge tone="info">{r.provider}</Badge>},
37
+ {key:'is_active',label:'Active',sortable:true,render:r=><Badge tone={r.is_active?'success':'muted'}>{r.is_active?'active':'inactive'}</Badge>},
38
+ {key:'test_mode',label:'Test mode',render:r=><Badge tone={r.test_mode?'warning':'muted'}>{r.test_mode?'yes':'no'}</Badge>},
39
+ {key:'modified',label:'Modified',sortable:true,render:r=>fmt.datetime(r.modified)},
40
+ ];
41
+
42
+ function PhoneDetail({id,onClose}:{id:number;onClose:()=>void}){const query=PhoneNumberModel.useOne(id);const canDelete=useCan(PHONE_NUMBER_DELETE_PERMISSIONS).can;const destroy=PhoneNumberModel.useDelete();if(!query.data)return <div className="modal-pad">{query.isLoading?'Loading phone record…':'Phone record unavailable'}<button className="btn" onClick={onClose}>Close</button></div>;const row=query.data;const remove=async()=>{await destroy.mutateAsync({id});toast.success('Local phone cache row deleted');onClose();};return <DetailView title={row.phone_number} subtitle={`${row.carrier||'Unknown carrier'} · ${row.line_type||'Unknown line type'}`} icon="bi-telephone" onClose={onClose} sections={[{key:'lookup',label:'Lookup',icon:'bi-search',render:()=><div className="detail-section"><FlatRow label="Provider">{row.lookup_provider||'—'}</FlatRow><FlatRow label="Lookup count">{row.lookup_count}</FlatRow><FlatRow label="Last lookup">{row.last_lookup_at?fmt.datetime(row.last_lookup_at):'Never'}</FlatRow><FlatRow label="Expires">{row.lookup_expires_at?fmt.datetime(row.lookup_expires_at):'Unknown'}</FlatRow><p className="dim">Lookup payloads are deliberately excluded from the client cache.</p></div>},{key:'identity',label:'Carrier & owner',icon:'bi-person-lines-fill',render:()=><div className="detail-section"><FlatRow label="Valid">{row.is_valid?'Yes':'No'}</FlatRow><FlatRow label="Mobile">{row.is_mobile?'Yes':'No'}</FlatRow><FlatRow label="VoIP">{row.is_voip?'Yes':'No'}</FlatRow><FlatRow label="Registered owner">{row.registered_owner||'—'}</FlatRow><FlatRow label="Region">{[row.region,row.state].filter(Boolean).join(', ')||'—'}</FlatRow></div>},...(canDelete?[{key:'delete',label:'Delete',icon:'bi-trash',render:()=><div className="detail-section"><p>Deletes this global cached phone record only. It does not affect provider data.</p><ArmedButton label="Delete cached record" armedLabel="Click again to delete" onConfirm={remove}/></div>}]:[])]}/>;}
43
+
44
+ function LookupDialog({close}:{close:(row:PhoneNumberRow|null)=>void}){const qc=useQueryClient();const [value,setValue]=useState('');const [busy,setBusy]=useState(false);const [error,setError]=useState('');const submit=async()=>{setBusy(true);setError('');try{const normalized=await import('./api').then(api=>api.normalizePhoneNumber(value));let previous:PhoneNumberRow|undefined;try{const existing=await import('../../client').then(api=>api.mojoList<PhoneNumberRow>(PhoneNumberModel.endpoint,{phone_number:normalized,graph:'default',start:0,size:1}));previous=existing.rows[0];}catch{/* lookup remains available for a new row */}const row=await lookupPhoneNumber(normalized,previous,()=>window.confirm('This cache row is still fresh. Force a paid global Twilio Lookup anyway?'));qc.setQueryData(PhoneNumberModel.keys.one(row.id),row);await qc.invalidateQueries({queryKey:PhoneNumberModel.keys.root});toast.success('Lookup completed with refresh evidence');close(row);}catch(reason){setError(reason instanceof Error?reason.message:'Lookup failed');}finally{setBusy(false);}};return <div className="modal-pad"><h2 className="modal-title">Lookup phone number</h2><p className="dim">The number is normalized first. New or expired rows refresh automatically; a known-fresh row requires confirmation before a paid global Twilio Lookup.</p>{error&&<div className="form-alert" role="alert">{error}</div>}<label className="field"><span className="field-label">Phone number</span><input className="input" value={value} onChange={e=>setValue(e.target.value)} placeholder="+1 415 555 0100"/></label><div className="modal-actions"><button className="btn" disabled={busy} onClick={()=>close(null)}>Cancel</button><button className="btn btn-primary" disabled={busy||!value.trim()} onClick={()=>void submit()}>{busy?'Looking up…':'Normalize and look up'}</button></div></div>;}
45
+
46
+ export function PhoneNumbersPage(){const canLookup=useCan(PHONE_LOOKUP_UI_PERMISSIONS).can;return <ModelTable model={PhoneNumberModel} eyebrow="Communications · Phone Hub" title="Phone Numbers" columns={phoneColumns} defaultSort="phone_number" searchable searchPlaceholder="Search number, carrier, or owner" columnChooser persistState persistKey="admin:phonehub:numbers" exportFormats={['csv','json']} exporter={exportPhoneNumbers} addLabel="Lookup number" onAdd={canLookup?()=>void modal.open(close=><LookupDialog close={close}/>):undefined} onRowClick={row=>void modal.detail(close=><PhoneDetail id={row.id} onClose={()=>close(null)}/>)}/>;}
47
+
48
+ function SmsDetail({id,onClose}:{id:number;onClose:()=>void}){const query=SmsModel.useOne(id);const canDelete=useCan(SMS_DELETE_PERMISSIONS).can;const destroy=SmsModel.useDelete();if(!query.data)return <div className="modal-pad">{query.isLoading?'Loading SMS audit…':'SMS audit unavailable'}<button className="btn" onClick={onClose}>Close</button></div>;const row=query.data;const remove=async()=>{await destroy.mutateAsync({id});toast.success('Local SMS audit row deleted');onClose();};return <DetailView title={`${row.from_number} → ${row.to_number}`} subtitle={`${row.direction} · ${row.status}`} icon="bi-chat-square-text" onClose={onClose} sections={[{key:'message',label:'Message',icon:'bi-chat-text',render:()=><div className="detail-section"><FlatRow label="Created">{fmt.datetime(row.created)}</FlatRow><FlatRow label="Provider">{row.provider||'—'}</FlatRow><FlatRow label="Group">{relationLabel(row.group,'None')}</FlatRow><div className="known-card"><h3>Message body</h3><p className="pre-wrap">{row.body||'—'}</p></div>{row.error_message&&<div className="form-alert">{row.error_message}</div>}</div>},...(canDelete?[{key:'delete',label:'Delete',icon:'bi-trash',render:()=><div className="detail-section"><p>Deletes the local audit row only. It cannot recall or alter a provider message. SMS rows are deleted when their owning user or group is deleted; deleting a provider config does not delete them.</p><ArmedButton label="Delete SMS audit row" armedLabel="Click again to delete" onConfirm={remove}/></div>}]:[])]}/>;}
49
+ export function SmsPage(){return <ModelTable model={SmsModel} eyebrow="Communications · Phone Hub" title="SMS Audit" columns={smsColumns} filters={smsFilters} defaultSort="-created" searchable searchPlaceholder="Search number or message body" autoRefresh={20} columnChooser persistState persistKey="admin:phonehub:sms" exportFormats={['csv','json']} exporter={exportSmsAudits} onRowClick={row=>void modal.detail(close=><SmsDetail id={row.id} onClose={()=>close(null)}/>)}/>;}
50
+
51
+ const credentialLabels:Record<PhoneSecretField,string>={twilio_account_sid:'Twilio account SID',twilio_auth_token:'Twilio auth token',aws_access_key_id:'AWS access key id',aws_secret_access_key:'AWS secret access key',mojo_api_key:'Mojo service API key'};
52
+ function ConfigEditor({row,close}:{row?:PhoneConfigRow;close:(value:PhoneConfigRow|null)=>void}){const qc=useQueryClient();const canGroups=useCan(PHONE_GROUP_DIRECTORY_PERMISSIONS).can;const groups=useQuery({queryKey:['phonehub','group-choices'],queryFn:fetchPhoneGroupChoices,enabled:canGroups,staleTime:60_000});const [name,setName]=useState(row?.name??'');const [provider,setProvider]=useState<PhoneConfigRow['provider']>(row?.provider??'twilio');const [group,setGroup]=useState(row?.group==null?'':String(typeof row.group==='number'?row.group:row.group.id));const [active,setActive]=useState(row?.is_active??true);const [testMode,setTestMode]=useState(row?.test_mode??false);const [lookup,setLookup]=useState(row?.lookup_enabled??true);const [cacheDays,setCacheDays]=useState(row?.lookup_cache_days??90);const [twilioFrom,setTwilioFrom]=useState(row?.twilio_from_number??'');const [awsRegion,setAwsRegion]=useState(row?.aws_region??'us-east-1');const [awsSender,setAwsSender]=useState(row?.aws_sender_id??'');const [mojoUrl,setMojoUrl]=useState(row?.mojo_remote_url??'');const refs=useRef<Partial<Record<PhoneSecretField,HTMLInputElement|null>>>({});const [clearFields,setClearFields]=useState<PhoneSecretField[]>([]);const [busy,setBusy]=useState(false);const [error,setError]=useState('');const toggleClear=(field:PhoneSecretField)=>{if(clearFields.includes(field)){setClearFields(clearFields.filter(v=>v!==field));return;}if(window.confirm(`Clear the stored ${credentialLabels[field]} on save?`))setClearFields([...clearFields,field]);};const submit=async()=>{setBusy(true);setError('');const credentials:Partial<Record<PhoneSecretField,SecretEdit>>={};for(const field of Object.keys(credentialLabels) as PhoneSecretField[]){const value=refs.current[field]?.value??'';credentials[field]=clearFields.includes(field)?{mode:'clear',confirmed:true}:value?{mode:'replace',value}:{mode:'untouched'};}try{buildPhoneConfigPayload({},credentials);const saved=await savePhoneConfigImperative(qc,row?.id??null,{name:name.trim(),provider,group:group?Number(group):null,is_active:active,test_mode:testMode,lookup_enabled:lookup,lookup_cache_days:cacheDays,twilio_from_number:twilioFrom||null,aws_region:awsRegion||null,aws_sender_id:awsSender||null,mojo_remote_url:mojoUrl||null},credentials);for(const field of Object.keys(credentialLabels) as PhoneSecretField[])if(refs.current[field])refs.current[field]!.value='';toast.success(row?'Provider configuration updated':'Provider configuration created');close(saved);}catch(reason){setError(reason instanceof Error?reason.message:'Configuration could not be saved');}finally{setBusy(false);}};return <div className="modal-pad phonehub-config-editor"><h2 className="modal-title">{row?'Edit':'Create'} provider configuration</h2><p className="dim">Credentials are write-only. Leave a field blank to keep it, type a replacement, or explicitly confirm Clear. Empty strings are never submitted.</p>{error&&<div className="form-alert" role="alert">{error}</div>}<div className="form-grid"><label className="field col-8"><span className="field-label">Name</span><input className="input" value={name} onChange={e=>setName(e.target.value)}/></label><label className="field col-4"><span className="field-label">Provider</span><select className="input" value={provider} onChange={e=>setProvider(e.target.value as PhoneConfigRow['provider'])}><option value="twilio">Twilio</option><option value="aws">AWS</option><option value="mojo">Mojo</option></select></label>{canGroups?<label className="field col-12"><span className="field-label">Group scope</span><select className="input" value={group} onChange={e=>setGroup(e.target.value)}><option value="">System default</option>{groups.data?.map(item=><option key={item.id} value={item.id}>{item.name}</option>)}</select><span className="field-help">Choices are a bounded global directory projection (maximum 100).</span></label>:<div className="field col-12"><span className="field-label">Group scope</span><div className="input" aria-readonly="true">{relationLabel(row?.group??null)}</div><span className="field-help">Group choices stay hidden without global group-directory permission.</span></div>}<label className="field col-6"><span className="field-label">Twilio from number</span><input className="input" value={twilioFrom} onChange={e=>setTwilioFrom(e.target.value)}/></label><label className="field col-6"><span className="field-label">AWS region</span><input className="input" value={awsRegion} onChange={e=>setAwsRegion(e.target.value)}/></label><label className="field col-6"><span className="field-label">AWS sender id</span><input className="input" value={awsSender} onChange={e=>setAwsSender(e.target.value)}/></label><label className="field col-6"><span className="field-label">Mojo remote URL</span><input className="input" value={mojoUrl} onChange={e=>setMojoUrl(e.target.value)}/></label>{(Object.keys(credentialLabels) as PhoneSecretField[]).map(field=><label className="field col-6" key={field}><span className="field-label">{credentialLabels[field]}</span><div className="phonehub-secret-row"><input ref={node=>{refs.current[field]=node;}} className="input" type="password" autoComplete="new-password" disabled={clearFields.includes(field)}/><button type="button" className={`btn${clearFields.includes(field)?' btn-danger':''}`} onClick={()=>toggleClear(field)}>{clearFields.includes(field)?'Keep instead':'Clear…'}</button></div></label>)}<label className="check col-4"><input type="checkbox" checked={active} onChange={e=>setActive(e.target.checked)}/><span>Active</span></label><label className="check col-4"><input type="checkbox" checked={testMode} onChange={e=>setTestMode(e.target.checked)}/><span>Test mode</span></label><label className="check col-4"><input type="checkbox" checked={lookup} onChange={e=>setLookup(e.target.checked)}/><span>Lookup enabled</span></label><label className="field col-4"><span className="field-label">Lookup cache days</span><input className="input" type="number" min="1" value={cacheDays} onChange={e=>setCacheDays(Number(e.target.value))}/></label></div><div className="modal-actions"><button className="btn" disabled={busy} onClick={()=>close(null)}>Cancel</button><button className="btn btn-primary" disabled={busy||!name.trim()} onClick={()=>void submit()}>{busy?'Saving…':'Save configuration'}</button></div></div>;}
53
+
54
+ function ConfigDetail({id,onClose}:{id:number;onClose:()=>void}){const query=PhoneConfigModel.useOne(id);const canManage=useCan(PHONE_CONFIG_MANAGE_PERMISSIONS).can;const canDelete=useCan(PHONE_CONFIG_DELETE_PERMISSIONS).can;const destroy=PhoneConfigModel.useDelete();if(!query.data)return <div className="modal-pad">{query.isLoading?'Loading provider configuration…':'Configuration unavailable'}<button className="btn" onClick={onClose}>Close</button></div>;const row=query.data;const edit=()=>void modal.open(close=><ConfigEditor row={row} close={async saved=>{close(saved);if(saved)await query.refetch();}}/>,{size:'lg'});const test=async()=>{try{const result=await testPhoneConfigImperative(id);toast[result.status?'success':'error'](result.message);}catch(reason){toast.error(reason instanceof Error?reason.message:'Connection test failed');}};const remove=async()=>{await destroy.mutateAsync({id});toast.success('Provider configuration deleted');onClose();};return <DetailView title={row.name} subtitle={`${relationLabel(row.group)} · ${row.provider}`} icon="bi-phone" onClose={onClose} sections={[{key:'configuration',label:'Configuration',icon:'bi-sliders',render:()=><div className="detail-section"><div className="storage-action-row">{canManage&&<><button className="btn" onClick={edit}>Edit</button><button className="btn" onClick={()=>void test()}>Test stored connection</button></>}</div><FlatRow label="Provider">{row.provider}</FlatRow><FlatRow label="Active">{row.is_active?'Yes':'No'}</FlatRow><FlatRow label="Test mode">{row.test_mode?'Yes':'No'}</FlatRow><FlatRow label="Lookup cache">{row.lookup_cache_days} days</FlatRow><p className="form-alert">Test mode only short-circuits the connection test; it does not prevent SMS sends. Current sending uses a per-group configuration only for the Mojo provider. Stored Twilio and AWS credentials are testable, but sending still follows global Twilio settings; AWS sending is not wired.</p><p className="dim">An active group config wins, otherwise the first active system default is used. Deleting or deactivating it falls back to the system default. Deleting a group cascades its config; deleting this config does not delete SMS audit rows.</p></div>},...(canDelete?[{key:'delete',label:'Delete',icon:'bi-trash',render:()=><div className="detail-section"><ArmedButton label="Delete provider configuration" armedLabel="Click again to delete" onConfirm={remove}/></div>}]:[])]}/>;}
55
+ export function ProviderConfigsPage(){const canManage=useCan(PHONE_CONFIG_MANAGE_PERMISSIONS).can;return <ModelTable model={PhoneConfigModel} eyebrow="Communications · Phone Hub" title="Provider Configurations" columns={configColumns} defaultSort="name" searchable searchPlaceholder="Search configuration names" columnChooser persistState persistKey="admin:phonehub:configs" exportFormats={['csv','json']} exporter={exportPhoneConfigs} addLabel="Create configuration" onAdd={canManage?()=>void modal.open(close=><ConfigEditor close={close}/>,{size:'lg'}):undefined} onRowClick={row=>void modal.detail(close=><ConfigDetail id={row.id} onClose={()=>close(null)}/>)}/>;}
56
+
57
+ export function PhoneHubPage(){const canNumbers=useCan(PHONE_NUMBER_VIEW_PERMISSIONS).can;const canSms=useCan(SMS_VIEW_PERMISSIONS).can;const canConfig=useCan(PHONE_CONFIG_VIEW_PERMISSIONS).can;const items=[...(canNumbers?[{key:'numbers',label:'Phone Numbers',panel:<PhoneNumbersPage/>}]:[]),...(canSms?[{key:'sms',label:'SMS',panel:<SmsPage/>}]:[]),...(canConfig?[{key:'config',label:'Provider Config',panel:<ProviderConfigsPage/>}]:[])];return <div className="phonehub-page"><div className="phonehub-heading"><p className="eyebrow">Communications</p><h1>Phone Hub</h1><p className="dim">Global phone intelligence, SMS audit history, and provider configuration. This surface never adopts the active product group.</p></div><Tabs ariaLabel="Phone Hub admin areas" variant="underline-all" items={items}/></div>;}
@@ -0,0 +1,47 @@
1
+ import type { QueryClient } from '@tanstack/react-query';
2
+ import { mojoCall, mojoList } from '../../client';
3
+ import { PhoneConfigModel, sanitizePhoneConfigRow, sanitizePhoneNumberRow, type PhoneConfigRow, type PhoneNumberRow, type PhoneRelation } from './models';
4
+
5
+ export const PHONE_GROUP_CHOICE_LIMIT=100;
6
+ export const PHONE_SECRET_FIELDS=['twilio_account_sid','twilio_auth_token','aws_access_key_id','aws_secret_access_key','mojo_api_key'] as const;
7
+ export type PhoneSecretField=(typeof PHONE_SECRET_FIELDS)[number];
8
+ export type SecretEdit={mode:'untouched'}|{mode:'replace';value:string}|{mode:'clear';confirmed:true};
9
+
10
+ export async function normalizePhoneNumber(phoneNumber:string,countryCode?:string):Promise<string>{
11
+ const body=await mojoCall('/api/phonehub/number/normalize',{method:'POST',body:{phone_number:phoneNumber,...(countryCode?{country_code:countryCode}:{})}});
12
+ const value=(body.data as {phone_number?:unknown}|undefined)?.phone_number;
13
+ if(typeof value!=='string'||!/^\+[1-9]\d{7,14}$/.test(value))throw new Error('The server did not return a valid E.164 phone number.');
14
+ return value;
15
+ }
16
+ export async function lookupPhoneNumber(phoneNumber:string,previous?:PhoneNumberRow,confirmFresh?:()=>boolean|Promise<boolean>):Promise<PhoneNumberRow>{
17
+ const normalized=await normalizePhoneNumber(phoneNumber);
18
+ const fresh=Boolean(previous?.lookup_expires_at&&previous.lookup_expires_at>Date.now()/1000);
19
+ if(fresh&&!(await confirmFresh?.()))throw new Error('Fresh lookup cancelled.');
20
+ const body=await mojoCall('/api/phonehub/number/lookup',{method:'POST',body:{phone_number:normalized,...(fresh?{force_refresh:true}:{})}});
21
+ const row=sanitizePhoneNumberRow(body.data as PhoneNumberRow);
22
+ const advanced=previous?row.lookup_count>previous.lookup_count||Boolean(row.last_lookup_at&&row.last_lookup_at!==(previous.last_lookup_at??null)):row.lookup_count>=1&&row.last_lookup_at!=null;
23
+ if(!advanced)throw new Error('The lookup provider returned no refresh evidence. The cached record was left unchanged.');
24
+ return row;
25
+ }
26
+ export function buildPhoneConfigPayload(scalars:Record<string,unknown>,credentials:Partial<Record<PhoneSecretField,SecretEdit>>):Record<string,unknown>{
27
+ const body={...scalars};
28
+ for(const field of PHONE_SECRET_FIELDS){const edit=credentials[field];if(!edit||edit.mode==='untouched')continue;if(edit.mode==='clear')body[field]=null;else{const value=edit.value.trim();if(!value)throw new Error(`${field} replacements cannot be empty.`);body[field]=value;}}
29
+ return body;
30
+ }
31
+ export async function savePhoneConfigImperative(qc:QueryClient,id:number|null,scalars:Record<string,unknown>,credentials:Partial<Record<PhoneSecretField,SecretEdit>>):Promise<PhoneConfigRow>{
32
+ const payload=buildPhoneConfigPayload(scalars,credentials);
33
+ const path=id==null?PhoneConfigModel.endpoint:`${PhoneConfigModel.endpoint}/${id}`;
34
+ const response=await mojoCall(path,{method:'POST',body:payload});
35
+ for(const field of PHONE_SECRET_FIELDS)delete payload[field];
36
+ const row=sanitizePhoneConfigRow(response.data as PhoneConfigRow);
37
+ qc.setQueryData(PhoneConfigModel.keys.one(row.id),row);await qc.invalidateQueries({queryKey:PhoneConfigModel.keys.root});return row;
38
+ }
39
+ export async function testPhoneConfigImperative(id:number):Promise<{status:boolean;message:string}>{
40
+ const response=await mojoCall(`${PhoneConfigModel.endpoint}/${id}`,{method:'POST',body:{test_connection:1}});
41
+ const data=(response.data??response) as Record<string,unknown>;
42
+ return {status:data.status!==false,message:typeof data.message==='string'?data.message:'Connection test completed.'};
43
+ }
44
+ export async function fetchPhoneGroupChoices():Promise<PhoneRelation[]>{
45
+ const result=await mojoList<Record<string,unknown>>('/api/group',{graph:'basic',sort:'name',start:0,size:PHONE_GROUP_CHOICE_LIMIT});
46
+ return result.rows.flatMap(row=>{const id=Number(row.id);return Number.isFinite(id)&&typeof row.name==='string'?[{id,name:row.name}]:[];});
47
+ }
@@ -0,0 +1,6 @@
1
+ import { createSafeExporter } from '../../client';
2
+ import { PhoneConfigModel, PhoneNumberModel, SmsModel, sanitizePhoneConfigRow, sanitizePhoneNumberRow, sanitizeSmsRow, type PhoneConfigRow, type PhoneNumberRow, type SmsRow } from './models';
3
+ const sheet=(value:unknown)=>typeof value==='string'&&/^[\t\r\n\u0000-\u0020]*[=+\-@]/.test(value)?`'${value}`:value;
4
+ export const exportPhoneNumbers=createSafeExporter<PhoneNumberRow>({endpoint:PhoneNumberModel.endpoint,filename:'phone-numbers',sanitizeRow:sanitizePhoneNumberRow,fields:[{key:'id'},{key:'phone_number',value:r=>sheet(r.phone_number)},{key:'country_code'},{key:'carrier',value:r=>sheet(r.carrier)},{key:'line_type'},{key:'is_mobile'},{key:'is_voip'},{key:'is_valid'},{key:'lookup_provider'},{key:'lookup_count'},{key:'last_lookup_at'},{key:'created'}]});
5
+ export const exportSmsAudits=createSafeExporter<SmsRow>({endpoint:SmsModel.endpoint,filename:'sms-audit',sanitizeRow:sanitizeSmsRow,fields:[{key:'id'},{key:'created'},{key:'direction'},{key:'from_number',value:r=>sheet(r.from_number)},{key:'to_number',value:r=>sheet(r.to_number)},{key:'status'},{key:'provider'},{key:'sent_at'},{key:'delivered_at'}]});
6
+ export const exportPhoneConfigs=createSafeExporter<PhoneConfigRow>({endpoint:PhoneConfigModel.endpoint,filename:'phone-provider-configs',sanitizeRow:sanitizePhoneConfigRow,fields:[{key:'id'},{key:'name',value:r=>sheet(r.name)},{key:'provider'},{key:'is_active'},{key:'test_mode'},{key:'lookup_enabled'},{key:'lookup_cache_days'},{key:'created'},{key:'modified'}]});
@@ -0,0 +1,10 @@
1
+ import type { AdminSection } from '../index';
2
+ import { PhoneHubPage } from './PhoneHubPage';
3
+ import { PHONE_HUB_ADMIN_PERMISSIONS } from './models';
4
+
5
+ export * from './models';
6
+ export * from './api';
7
+ export * from './data';
8
+ export * from './PhoneHubPage';
9
+
10
+ export const PHONE_HUB_ADMIN_SECTION:AdminSection={id:'phonehub',basePath:'phonehub',title:'Phone Hub',icon:'bi-phone',navigationGroup:'communications',permissions:PHONE_HUB_ADMIN_PERMISSIONS,routes:[{path:'',label:'Phone Hub',component:PhoneHubPage,permissions:PHONE_HUB_ADMIN_PERMISSIONS}]};
@@ -0,0 +1,68 @@
1
+ import { defineModel, type Params } from '../../client';
2
+
3
+ export const PHONE_NUMBER_VIEW_PERMISSIONS = ['sys.view_phone_numbers', 'sys.manage_phone_numbers', 'sys.comms', 'sys.manage_users'];
4
+ export const PHONE_NUMBER_MANAGE_PERMISSIONS = ['sys.manage_phone_numbers', 'sys.comms', 'sys.manage_users'];
5
+ export const PHONE_NUMBER_DELETE_PERMISSIONS = ['sys.manage_phone_numbers'];
6
+ export const PHONE_LOOKUP_UI_PERMISSIONS = PHONE_NUMBER_MANAGE_PERMISSIONS;
7
+ export const SMS_VIEW_PERMISSIONS = ['sys.view_sms', 'sys.manage_sms', 'sys.comms', 'sys.owner', 'sys.manage_notifications'];
8
+ export const SMS_MANAGE_PERMISSIONS = ['sys.manage_sms', 'sys.comms', 'sys.manage_notifications'];
9
+ export const SMS_DELETE_PERMISSIONS = ['sys.manage_sms', 'sys.manage_notifications'];
10
+ export const PHONE_CONFIG_VIEW_PERMISSIONS = ['sys.manage_phone_config', 'sys.manage_groups', 'sys.comms'];
11
+ export const PHONE_CONFIG_MANAGE_PERMISSIONS = PHONE_CONFIG_VIEW_PERMISSIONS;
12
+ export const PHONE_CONFIG_DELETE_PERMISSIONS = ['sys.manage_phone_config', 'sys.manage_groups'];
13
+ export const PHONE_HUB_ADMIN_PERMISSIONS = [...new Set([...PHONE_NUMBER_VIEW_PERMISSIONS, ...SMS_VIEW_PERMISSIONS, ...PHONE_CONFIG_VIEW_PERMISSIONS])];
14
+ export const PHONE_GROUP_DIRECTORY_PERMISSIONS = ['sys.view_groups', 'sys.manage_groups', 'sys.manage_group', 'sys.groups'];
15
+
16
+ export interface PhoneRelation { id: number; name?: string; display_name?: string; email?: string }
17
+ export interface PhoneNumberRow {
18
+ id:number; created:number; modified:number; phone_number:string; country_code:string|null; region:string|null; state:string|null;
19
+ carrier:string|null; line_type:string|null; is_mobile:boolean; is_voip:boolean; is_valid:boolean;
20
+ registered_owner:string|null; owner_type:string|null; address_line1:string|null; address_city:string|null;
21
+ address_state:string|null; address_zip:string|null; address_country:string|null; lookup_provider:string|null;
22
+ lookup_expires_at:number|null; lookup_count:number; last_lookup_at:number|null;
23
+ }
24
+ export interface SmsRow {
25
+ id:number; created:number; modified?:number; direction:string; from_number:string; to_number:string; body:string;
26
+ status:string; provider:string|null; error_message:string|null; sent_at:number|null; delivered_at:number|null;
27
+ user:PhoneRelation|number|null; group:PhoneRelation|number|null;
28
+ }
29
+ export interface PhoneConfigRow {
30
+ id:number; created:number; modified:number; group:PhoneRelation|number|null; name:string; is_active:boolean;
31
+ provider:'twilio'|'aws'|'mojo'; twilio_from_number:string|null; aws_region:string|null; aws_sender_id:string|null;
32
+ mojo_remote_url:string|null; lookup_enabled:boolean; lookup_cache_days:number; test_mode:boolean;
33
+ }
34
+
35
+ const COMMON = new Set(['start','size','search']);
36
+ function listParams(params:Params, graph:string, filters:readonly string[], sorts:readonly string[], defaultSort:string, dateRange=false):Params {
37
+ const out:Params={graph,start:params.start??0,size:params.size??25,sort:defaultSort};
38
+ for(const key of COMMON)if(params[key]!=null&&params[key]!=='')out[key]=params[key];
39
+ for(const key of filters)if(params[key]!=null&&params[key]!=='')out[key]=params[key];
40
+ const requested=typeof params.sort==='string'?params.sort:'';
41
+ if(sorts.includes(requested.replace(/^-/,'')))out.sort=requested;
42
+ if(dateRange&&(params.dr_start||params.dr_end)){out.dr_field='created';if(params.dr_start)out.dr_start=params.dr_start;if(params.dr_end)out.dr_end=params.dr_end;}
43
+ return out;
44
+ }
45
+ const relation=(value:unknown):PhoneRelation|number|null=>{
46
+ if(typeof value==='number')return value;
47
+ if(!value||typeof value!=='object')return null;
48
+ const row=value as Record<string,unknown>;const id=Number(row.id);if(!Number.isFinite(id))return null;
49
+ return {id,...(typeof row.name==='string'?{name:row.name}:{}),...(typeof row.display_name==='string'?{display_name:row.display_name}:{}),...(typeof row.email==='string'?{email:row.email}:{})};
50
+ };
51
+ const text=(value:unknown)=>typeof value==='string'?value:null;
52
+ const num=(value:unknown)=>typeof value==='number'&&Number.isFinite(value)?value:null;
53
+ export function sanitizePhoneNumberRow(input:PhoneNumberRow):PhoneNumberRow {
54
+ const row=input as unknown as Record<string,unknown>;
55
+ return {id:Number(row.id),created:Number(row.created??0),modified:Number(row.modified??0),phone_number:String(row.phone_number??''),country_code:text(row.country_code),region:text(row.region),state:text(row.state),carrier:text(row.carrier),line_type:text(row.line_type),is_mobile:Boolean(row.is_mobile),is_voip:Boolean(row.is_voip),is_valid:Boolean(row.is_valid),registered_owner:text(row.registered_owner),owner_type:text(row.owner_type),address_line1:text(row.address_line1),address_city:text(row.address_city),address_state:text(row.address_state),address_zip:text(row.address_zip),address_country:text(row.address_country),lookup_provider:text(row.lookup_provider),lookup_expires_at:num(row.lookup_expires_at),lookup_count:Number(row.lookup_count??0),last_lookup_at:num(row.last_lookup_at)};
56
+ }
57
+ export function sanitizeSmsRow(input:SmsRow):SmsRow {
58
+ const row=input as unknown as Record<string,unknown>;
59
+ return {id:Number(row.id),created:Number(row.created??0),modified:num(row.modified)??undefined,direction:String(row.direction??''),from_number:String(row.from_number??''),to_number:String(row.to_number??''),body:String(row.body??''),status:String(row.status??''),provider:text(row.provider),error_message:text(row.error_message),sent_at:num(row.sent_at),delivered_at:num(row.delivered_at),user:relation(row.user),group:relation(row.group)};
60
+ }
61
+ export function sanitizePhoneConfigRow(input:PhoneConfigRow):PhoneConfigRow {
62
+ const row=input as unknown as Record<string,unknown>;const provider=['twilio','aws','mojo'].includes(String(row.provider))?String(row.provider) as PhoneConfigRow['provider']:'twilio';
63
+ return {id:Number(row.id),created:Number(row.created??0),modified:Number(row.modified??0),group:relation(row.group),name:String(row.name??''),is_active:Boolean(row.is_active),provider,twilio_from_number:text(row.twilio_from_number),aws_region:text(row.aws_region),aws_sender_id:text(row.aws_sender_id),mojo_remote_url:text(row.mojo_remote_url),lookup_enabled:Boolean(row.lookup_enabled),lookup_cache_days:Number(row.lookup_cache_days??90),test_mode:Boolean(row.test_mode)};
64
+ }
65
+
66
+ export const PhoneNumberModel=defineModel<PhoneNumberRow>({name:'phone-number',endpoint:'/api/phonehub/number',permissions:{view:PHONE_NUMBER_VIEW_PERMISSIONS,manage:PHONE_NUMBER_MANAGE_PERMISSIONS,delete:PHONE_NUMBER_DELETE_PERMISSIONS},normalizeListParams:p=>listParams(p,'default',[],['phone_number','carrier','line_type','lookup_count','last_lookup_at','created','modified'],'phone_number'),sanitizeRow:sanitizePhoneNumberRow});
67
+ export const SmsModel=defineModel<SmsRow>({name:'sms-audit',endpoint:'/api/phonehub/sms',permissions:{view:SMS_VIEW_PERMISSIONS,manage:SMS_MANAGE_PERMISSIONS,delete:SMS_DELETE_PERMISSIONS},normalizeListParams:p=>listParams(p,'default',['direction','status','provider','group','user'],['created','direction','status','provider','sent_at','delivered_at'],'-created',true),sanitizeRow:sanitizeSmsRow});
68
+ export const PhoneConfigModel=defineModel<PhoneConfigRow>({name:'phone-config',endpoint:'/api/phonehub/config',permissions:{view:PHONE_CONFIG_VIEW_PERMISSIONS,manage:PHONE_CONFIG_MANAGE_PERMISSIONS,delete:PHONE_CONFIG_DELETE_PERMISSIONS},normalizeListParams:p=>listParams(p,'default',['provider','is_active','group','test_mode'],['name','provider','is_active','created','modified'],'name'),sanitizeRow:sanitizePhoneConfigRow});
@@ -0,0 +1,51 @@
1
+ import { useEffect, useMemo, useState } from 'react';
2
+ import { registerFieldType, type RegistryFieldProps } from '../../ui/field-registry';
3
+ import {
4
+ HANDLER_DEFINITIONS, addHandlerStep, moveHandlerStep, parseHandlerChain, removeHandlerStep,
5
+ serializeHandlerChain, updateHandlerStep, validateHandlerChain, type HandlerChain, type HandlerScheme,
6
+ } from './handler-dsl';
7
+
8
+ export interface HandlerChainBuilderProps {
9
+ value: string;
10
+ onChange: (value: string) => void;
11
+ disabled?: boolean;
12
+ onValidationChange?: (valid: boolean, messages: string[]) => void;
13
+ }
14
+
15
+ function confirmLegacy(message: string): boolean {
16
+ return typeof window !== 'undefined' && window.confirm(`${message}\n\nThis can change which handlers django-mojo dispatches. Continue?`);
17
+ }
18
+
19
+ export function HandlerChainBuilder({ value, onChange, disabled = false, onValidationChange }: HandlerChainBuilderProps) {
20
+ const [chain, setChain] = useState<HandlerChain>(() => parseHandlerChain(value));
21
+ useEffect(() => { if (serializeHandlerChain(chain) !== value) setChain(parseHandlerChain(value)); }, [value]); // eslint-disable-line react-hooks/exhaustive-deps
22
+ const issues = useMemo(() => validateHandlerChain(chain), [chain]);
23
+ useEffect(() => onValidationChange?.(!issues.some((issue) => issue.level === 'error'), issues.map((issue) => issue.message)), [issues, onValidationChange]);
24
+ const commit = (next: HandlerChain) => { setChain(next); onChange(serializeHandlerChain(next)); };
25
+ const guarded = (indexes: number[], operation: (confirm: boolean) => HandlerChain, message: string) => {
26
+ const legacy = indexes.some((index) => chain.steps[index]?.runtime !== 'effective');
27
+ if (!legacy) return commit(operation(false));
28
+ if (confirmLegacy(message)) commit(operation(true));
29
+ };
30
+ return <div className="handler-chain-builder">
31
+ <div className="handler-chain-heading"><div><strong>Dispatch chain</strong><small>Order controls publication order; async completion may differ.</small></div><select className="input input-compact" aria-label="Add handler" disabled={disabled} value="" onChange={(event) => { if (event.target.value) commit(addHandlerStep(chain, event.target.value as HandlerScheme)); }}><option value="">Add handler…</option>{HANDLER_DEFINITIONS.map((definition) => <option key={definition.scheme} value={definition.scheme}>{definition.label}</option>)}</select></div>
32
+ {!chain.steps.length && <div className="handler-chain-empty">No handler: matching events are recorded without dispatching an action.</div>}
33
+ {chain.steps.map((step, index) => {
34
+ const definition = HANDLER_DEFINITIONS.find((item) => item.scheme === step.scheme);
35
+ const stepIssues = issues.filter((issue) => issue.step === index);
36
+ return <section className={`handler-step${step.runtime !== 'effective' ? ' handler-step-warning' : ''}`} key={`${index}:${step.raw}`}>
37
+ <header><span className="handler-order">{index + 1}</span><div><strong>{definition?.label ?? `${step.scheme ?? 'Malformed'} handler`}</strong><small>{step.runtime === 'effective' ? 'Runtime effective' : step.runtime === 'swallowed' ? `Swallowed into step ${(step.runtimeOwner ?? 0) + 1}` : 'Skipped by runtime'}</small></div><div className="handler-order-controls"><button type="button" className="btn btn-compact" disabled={disabled || index === 0} aria-label={`Move handler ${index + 1} up`} onClick={() => guarded([index, index - 1], (confirmed) => moveHandlerStep(chain, index, index - 1, { confirmBehaviorChange: confirmed }), 'Move legacy content?')}>↑</button><button type="button" className="btn btn-compact" disabled={disabled || index === chain.steps.length - 1} aria-label={`Move handler ${index + 1} down`} onClick={() => guarded([index, index + 1], (confirmed) => moveHandlerStep(chain, index, index + 1, { confirmBehaviorChange: confirmed }), 'Move legacy content?')}>↓</button><button type="button" className="btn btn-compact btn-danger" disabled={disabled} onClick={() => guarded([index], (confirmed) => removeHandlerStep(chain, index, { confirmBehaviorChange: confirmed }), 'Remove legacy content?')}>Remove</button></div></header>
38
+ {step.supported && <div className="handler-step-fields"><label className="field"><span className="field-label">Type</span><select className="input" disabled={disabled} value={step.scheme ?? ''} onChange={(event) => commit(updateHandlerStep(chain, index, { scheme: event.target.value as HandlerScheme }))}>{HANDLER_DEFINITIONS.map((item) => <option key={item.scheme} value={item.scheme}>{item.label}</option>)}</select></label>{definition?.target !== 'none' && <label className="field"><span className="field-label">{definition?.target === 'targets' ? 'Targets (comma separated)' : 'Module path'}</span><input className="input" disabled={disabled} defaultValue={step.target} onBlur={(event) => { if (event.target.value !== step.target) commit(updateHandlerStep(chain, index, { target: event.target.value })); }} /></label>}</div>}
39
+ {step.supported && <details className="handler-advanced"><summary>Advanced ordered parameters</summary>{step.params.length ? step.params.map((param, paramIndex) => <div className="handler-param" key={`${paramIndex}:${param.key}`}><code>{param.key || '(blank key)'}</code><input className="input input-compact" disabled={disabled} defaultValue={param.value} onBlur={(event) => { if (event.target.value !== param.value) commit(updateHandlerStep(chain, index, { param: { key: param.key, value: event.target.value } })); }} /><button type="button" className="btn btn-compact" disabled={disabled} onClick={() => commit(updateHandlerStep(chain, index, { removeParam: param.key }))}>Remove</button></div>) : <p className="dim">No query parameters.</p>}<button type="button" className="btn btn-compact" disabled={disabled} onClick={() => { const key = window.prompt('Parameter name'); if (key != null) commit(updateHandlerStep(chain, index, { param: { key, value: '' } })); }}>Add parameter</button></details>}
40
+ <pre className="handler-raw">{step.raw}</pre>
41
+ {stepIssues.map((issue, issueIndex) => <div className={`handler-issue handler-issue-${issue.level}`} key={issueIndex}>{issue.message}</div>)}
42
+ </section>;
43
+ })}
44
+ </div>;
45
+ }
46
+
47
+ export function IncidentHandlerChainField({ value, disabled, commit }: RegistryFieldProps) {
48
+ return <HandlerChainBuilder value={typeof value === 'string' ? value : ''} disabled={disabled} onChange={commit} />;
49
+ }
50
+
51
+ registerFieldType('incident-handler-chain', IncidentHandlerChainField);
@@ -0,0 +1,33 @@
1
+ import { useMemo } from 'react';
2
+ import { useCan } from '../../client';
3
+ import { Badge, DetailView, FlatRow, JsonBlock, ModelTable, fmt, modal, toast, type BatchAction, type Column } from '../../ui';
4
+ import { HandlerChainBuilder } from './HandlerChainBuilder';
5
+ import { parseHandlerChain, runtimeEffectiveHandlerChain, validateHandlerChain } from './handler-dsl';
6
+ import { confirmCatchAllEnable, openHandlerChainEditor, openRuleEditor, openRuleSetEditor } from './editors';
7
+ import { BUNDLE_BY_OPTIONS, RULESET_MANAGE_PERMS, RuleModel, RuleSetModel, type RuleRow } from './models';
8
+
9
+ export function RuleSetDetail({ id, onClose }: { id: number; onClose: () => void }) {
10
+ const { can: canManage } = useCan(RULESET_MANAGE_PERMS); const query = RuleSetModel.useOne(Number.isFinite(id) ? id : null);
11
+ const rules = RuleModel.useList({ parent: id, size: 250, sort: 'index' }); const save = RuleSetModel.useSave(); const destroy = RuleSetModel.useDelete(); const destroyRule = RuleModel.useDelete();
12
+ const duplicateIndexes = useMemo(() => { const counts = new Map<number, number>(); for (const row of rules.data?.rows ?? []) counts.set(row.index, (counts.get(row.index) ?? 0) + 1); return [...counts].filter(([, count]) => count > 1).map(([index]) => index); }, [rules.data]);
13
+ if (!Number.isFinite(id)) return <div className="modal-pad text-bad">Invalid rule-set id. <button type="button" className="btn" onClick={onClose}>Close</button></div>;
14
+ if (query.isPending || !query.data) return <div className="modal-pad dim">Loading rule set… <button type="button" className="btn" onClick={onClose}>Close</button></div>;
15
+ const row = query.data; const chain = parseHandlerChain(row.handler); const handlerIssues = validateHandlerChain(chain); const nextIndex = Math.max(-1, ...(rules.data?.rows ?? []).map((rule) => rule.index)) + 1;
16
+ const ruleColumns: Column<RuleRow>[] = [
17
+ { key: 'index', label: 'Index', sortable: true, align: 'center', render: (rule) => <Badge tone={duplicateIndexes.includes(rule.index) ? 'warning' : 'muted'}>{rule.index}</Badge> },
18
+ { key: 'name', label: 'Condition', sortable: true, hideable: false }, { key: 'field_name', label: 'Field', render: (rule) => <code>{rule.field_name}</code> },
19
+ { key: 'comparator', label: 'Comparator', render: (rule) => <code>{rule.comparator}</code> }, { key: 'value', label: 'Value', render: (rule) => <code>{rule.value_type}:{rule.value}</code> },
20
+ { key: 'is_required', label: 'Required', render: (rule) => rule.is_required ? 'Yes' : 'No' },
21
+ ];
22
+ const ruleActions: BatchAction<RuleRow>[] = canManage ? [{ key: 'delete', label: 'Delete conditions', icon: 'bi-trash', danger: true, confirm: 'Delete the selected conditions? This cannot be undone.', run: (rule) => destroyRule.mutateAsync({ id: rule.id }) }] : [];
23
+ const setActive = async (next: boolean) => { if (next && !(await confirmCatchAllEnable(row, rules.data?.count ?? 0))) return; try { await save.mutateAsync({ id, changes: { is_active: next } }); toast.success(next ? 'Rule set enabled' : 'Rule set disabled'); } catch (error) { toast.error(error instanceof Error ? error.message : 'State change failed'); } };
24
+ const bundleLabel = BUNDLE_BY_OPTIONS.find((option) => Number(option.value) === row.bundle_by)?.label ?? `Unknown (${row.bundle_by})`;
25
+ return <DetailView icon="bi-diagram-3" title={row.name || `Rule set #${row.id}`} subtitle={`${row.category} · priority ${row.priority} · first active match wins`} chips={[{ text: row.is_active ? 'ACTIVE' : 'INACTIVE', tone: row.is_active ? 'success' : 'muted' }, { text: `${rules.data?.count ?? 0} CONDITIONS`, tone: duplicateIndexes.length ? 'warning' : 'info' }]} active={canManage ? { value: row.is_active, onChange: (next) => void setActive(next) } : undefined} onClose={onClose} menuContext={row} contextMenu={canManage ? [{ label: 'Edit rule set', onSelect: () => void openRuleSetEditor(row) }, { label: 'Edit handler chain', onSelect: () => void openHandlerChainEditor(row) }, { divider: true }, { label: 'Delete rule set and conditions', danger: true, onSelect: async () => { if (!(await modal.confirm({ title: 'Delete rule set?', message: `Delete “${row.name}” and all ${rules.data?.count ?? 0} conditions?`, confirmText: 'Delete rule set', danger: true }))) return; await destroy.mutateAsync({ id }); onClose(); } }] : []} sections={[
26
+ { key: 'overview', label: 'Overview', icon: 'bi-info-circle', render: () => <div className="detail-stack"><FlatRow label="State">{row.is_active ? 'Active' : 'Inactive'}</FlatRow><FlatRow label="Category"><code>{row.category}</code></FlatRow><FlatRow label="Priority">{row.priority} (lower is evaluated first within this category)</FlatRow><FlatRow label="Matching">{row.match_by === 0 ? 'All conditions' : row.match_by === 1 ? 'Any condition' : `Unknown (${row.match_by})`}</FlatRow><FlatRow label="Created">{fmt.datetime(row.created)}</FlatRow></div> },
27
+ { key: 'conditions', label: 'Conditions', icon: 'bi-funnel', render: () => <div>{duplicateIndexes.length > 0 && <div className="rule-priority-warning">Duplicate condition indexes {duplicateIndexes.join(', ')} have undefined tie order.</div>}<ModelTable model={RuleModel} title="Conditions" columns={ruleColumns} searchable={false} defaultParams={{ parent: id, sort: 'index' }} onRowClick={canManage ? (rule) => void openRuleEditor(id, rule, nextIndex) : undefined} {...(canManage ? { addLabel: 'Add condition', onAdd: () => void openRuleEditor(id, undefined, nextIndex), selectable: true, batchActions: ruleActions } : {})} /></div> },
28
+ { key: 'triggering', label: 'Triggering', icon: 'bi-lightning', render: () => <div className="detail-stack"><FlatRow label="Bundle by">{bundleLabel}</FlatRow><FlatRow label="Bundle window">{row.bundle_minutes == null ? 'Unbounded' : row.bundle_minutes === 0 ? 'Disabled (no time bundling)' : `${row.bundle_minutes} minutes`}</FlatRow><FlatRow label="Trigger">{row.trigger_count == null ? 'Immediately on first event' : `${row.trigger_count} events${row.trigger_window == null ? '' : ` within ${row.trigger_window} minutes`}`}</FlatRow><FlatRow label="Re-trigger">{row.retrigger_every == null ? 'Once per incident' : `Every ${row.retrigger_every} additional events`}</FlatRow></div> },
29
+ { key: 'handler', label: 'Handler', icon: 'bi-diagram-3', render: () => <div><div className="handler-section-actions"><p>{runtimeEffectiveHandlerChain(chain).length} runtime-effective dispatch step(s). Publication follows the order below; completion is asynchronous.</p>{canManage && <button type="button" className="btn btn-primary" onClick={() => void openHandlerChainEditor(row)}>Edit chain</button>}</div><HandlerChainBuilder value={row.handler ?? ''} onChange={() => undefined} disabled />{handlerIssues.length > 0 && <p className="dim">Legacy warnings remain visible and byte-preserved until explicitly changed.</p>}</div> },
30
+ { key: 'agent', label: 'Agent prompt', icon: 'bi-stars', render: () => <div className="detail-stack"><FlatRow label="Prompt">{typeof row.metadata.agent_prompt === 'string' ? row.metadata.agent_prompt : 'No agent prompt configured.'}</FlatRow><p className="dim">The prompt is stored in RuleSet metadata. General edits merge only targeted metadata keys.</p></div> },
31
+ { key: 'metadata', label: 'Metadata', icon: 'bi-braces', render: () => <JsonBlock value={row.metadata} /> },
32
+ ]} />;
33
+ }
@@ -0,0 +1,33 @@
1
+ import { useMemo } from 'react';
2
+ import { useCan } from '../../client';
3
+ import { Badge, ModelTable, fmt, modal, type Column, type FilterDef } from '../../ui';
4
+ import { parseHandlerChain } from './handler-dsl';
5
+ import { openRuleSetEditor } from './editors';
6
+ import { BUNDLE_BY_OPTIONS, RULESET_MANAGE_PERMS, RuleSetModel, type RuleSetRow } from './models';
7
+ import { RuleSetDetail } from './RuleSetDetailPage';
8
+
9
+ function handlerSummary(value: string | null) {
10
+ const chain = parseHandlerChain(value);
11
+ if (!chain.steps.length) return <span className="dim">Record only</span>;
12
+ return <span className="handler-chip-row">{chain.steps.map((step, index) => <Badge key={`${index}:${step.raw}`} tone={step.supported && step.runtime === 'effective' ? 'info' : 'warning'}>{step.scheme ?? 'malformed'}{step.runtime === 'effective' ? '' : ' ⚠'}</Badge>)}</span>;
13
+ }
14
+
15
+ export function RuleSetsPage() {
16
+ const { can: canManage } = useCan(RULESET_MANAGE_PERMS);
17
+ const inventory = RuleSetModel.useList({ size: 500, sort: 'priority' });
18
+ const ties = useMemo(() => {
19
+ const counts = new Map<string, number>();
20
+ for (const row of inventory.data?.rows ?? []) { const key = `${row.category}\u0000${row.priority}`; counts.set(key, (counts.get(key) ?? 0) + 1); }
21
+ return [...counts].filter(([, count]) => count > 1).map(([key]) => { const [category, priority] = key.split('\u0000'); return `${category} priority ${priority}`; });
22
+ }, [inventory.data]);
23
+ const columns: Column<RuleSetRow>[] = [
24
+ { key: 'priority', label: 'Priority', sortable: true, align: 'center', render: (row) => <Badge tone="primary">{row.priority}</Badge> },
25
+ { key: 'is_active', label: 'State', sortable: true, render: (row) => <Badge tone={row.is_active ? 'success' : 'muted'}>{row.is_active ? 'Active' : 'Inactive'}</Badge> },
26
+ { key: 'name', label: 'Rule set', sortable: true, hideable: false, render: (row) => <span><strong>{row.name || `Rule set #${row.id}`}</strong><small className="table-cell-subtitle"><code>{row.category}</code> · first active match wins</small></span> },
27
+ { key: 'bundle_by', label: 'Triggering', sortable: true, render: (row) => <span>{BUNDLE_BY_OPTIONS.find((option) => Number(option.value) === row.bundle_by)?.label ?? `Unknown (${row.bundle_by})`}<small className="table-cell-subtitle">{row.trigger_count == null ? 'Immediate' : `${row.trigger_count} events${row.trigger_window == null ? '' : ` / ${row.trigger_window}m`}`} · bundle {row.bundle_minutes == null ? 'unbounded' : `${row.bundle_minutes}m`}</small></span> },
28
+ { key: 'handler', label: 'Dispatch chain', render: (row) => handlerSummary(row.handler) },
29
+ { key: 'modified', label: 'Modified', sortable: true, render: (row) => fmt.datetime(row.modified) },
30
+ ];
31
+ const filters: FilterDef[] = [{ key: 'is_active', label: 'Active', type: 'boolean' }, { key: 'category', label: 'Category', type: 'text' }, { key: 'priority', label: 'Priority', type: 'number' }, { key: 'bundle_by', label: 'Bundle mode', type: 'select', options: BUNDLE_BY_OPTIONS.map((option) => ({ value: String(option.value), label: option.label })) }];
32
+ return <div className="admin-rules-page">{ties.length > 0 && <div className="rule-priority-warning"><strong>Undefined tie order:</strong> {ties.join(', ')}. Edit one priority; multi-row reorder is intentionally unavailable.</div>}<ModelTable model={RuleSetModel} title="Rule Engine" eyebrow="Security operations" columns={columns} filters={filters} searchable searchPlaceholder="Search rule-set names" defaultParams={{ sort: 'priority' }} presets={[{ key: 'all', label: 'All', params: {} }, { key: 'active', label: 'Active', params: { is_active: 'true' } }, { key: 'inactive', label: 'Inactive', params: { is_active: 'false' } }]} columnChooser persistState persistKey="admin-security-rules" onRowClick={(row) => void modal.detail((close) => <RuleSetDetail id={row.id} onClose={() => close(null)} />)} {...(canManage ? { addLabel: 'New inactive rule set', onAdd: () => void openRuleSetEditor() } : {})} /></div>;
33
+ }
@@ -0,0 +1,54 @@
1
+ import { useCallback, useMemo, useState } from 'react';
2
+ import { SchemaForm, modal, toast, type Field, type FormData } from '../../ui';
3
+ import { HandlerChainBuilder } from './HandlerChainBuilder';
4
+ import {
5
+ BUNDLE_BY_OPTIONS, COMPARATOR_OPTIONS, MATCH_BY_OPTIONS, RuleModel, RuleSetModel, VALUE_TYPE_OPTIONS,
6
+ optionsWithUnknownValue, ruleChanges, ruleSetChanges, type RuleRow, type RuleSetRow,
7
+ } from './models';
8
+
9
+ const RULESET_FIELDS = (row?: RuleSetRow): Field[] => [
10
+ { name: 'name', type: 'text', label: 'Name', required: true }, { name: 'category', type: 'text', label: 'Event category', required: true },
11
+ { name: 'priority', type: 'text', label: 'Priority', required: true, columns: 6 },
12
+ { name: 'match_by', type: 'select', label: 'Match mode', columns: 6, options: optionsWithUnknownValue(MATCH_BY_OPTIONS, row?.match_by) },
13
+ { name: 'bundle_by', type: 'select', label: 'Bundle by', columns: 6, options: optionsWithUnknownValue(BUNDLE_BY_OPTIONS, row?.bundle_by) },
14
+ { name: 'bundle_minutes', type: 'text', label: 'Bundle minutes (0 disables, blank is unbounded)', columns: 6 },
15
+ { name: 'trigger_count', type: 'text', label: 'Trigger count', columns: 6 }, { name: 'trigger_window', type: 'text', label: 'Trigger window (minutes)', columns: 6 },
16
+ { name: 'retrigger_every', type: 'text', label: 'Re-trigger every', columns: 6 }, { name: 'bundle_by_rule_set', type: 'switch', label: 'Include rule set in bundle key', columns: 6 },
17
+ { name: 'metadata.agent_prompt', type: 'textarea', label: 'Agent prompt', help: 'Merged into metadata without replacing unknown metadata keys.' },
18
+ ];
19
+
20
+ function RuleSetEditor({ row, close }: { row?: RuleSetRow; close: (saved: boolean) => void }) {
21
+ const save = RuleSetModel.useSave(); const creating = !row;
22
+ const initial: FormData = row ? { name: row.name ?? '', category: row.category, priority: row.priority, match_by: row.match_by, bundle_by: row.bundle_by, bundle_minutes: row.bundle_minutes, trigger_count: row.trigger_count, trigger_window: row.trigger_window, retrigger_every: row.retrigger_every, bundle_by_rule_set: row.bundle_by_rule_set, 'metadata.agent_prompt': typeof row.metadata.agent_prompt === 'string' ? row.metadata.agent_prompt : '' } : { name: '', category: '', priority: 10, match_by: 0, bundle_by: 4, bundle_minutes: 30, trigger_count: null, trigger_window: null, retrigger_every: null, bundle_by_rule_set: true, 'metadata.agent_prompt': '' };
23
+ return <div className="modal-pad"><h2 className="modal-title">{creating ? 'Create inactive rule set' : 'Edit rule set'}</h2>{creating && <div className="form-alert form-alert-info">New rule sets are always inactive. Add conditions and review the handler before enabling.</div>}<SchemaForm fields={RULESET_FIELDS(row)} initial={initial} submitText={creating ? 'Create inactive rule set' : 'Save changes'} onCancel={() => close(false)} onSubmit={async (data) => { await save.mutateAsync({ id: row?.id ?? null, changes: ruleSetChanges(data, creating, row) }); toast.success(creating ? 'Inactive rule set created' : 'Rule set updated'); close(true); }} /></div>;
24
+ }
25
+
26
+ export async function openRuleSetEditor(row?: RuleSetRow): Promise<boolean> { return (await modal.open<boolean>((close) => <RuleSetEditor row={row} close={close} />, { size: 'lg' })) ?? false; }
27
+
28
+ function RuleEditor({ parent, row, nextIndex, close }: { parent: number; row?: RuleRow; nextIndex: number; close: (saved: boolean) => void }) {
29
+ const save = RuleModel.useSave(); const creating = !row;
30
+ const fields: Field[] = [
31
+ { name: 'name', type: 'text', label: 'Condition name', required: true }, { name: 'index', type: 'text', label: 'Evaluation index', required: true, columns: 6 },
32
+ { name: 'field_name', type: 'text', label: 'Event or metadata field', required: true, columns: 6 },
33
+ { name: 'comparator', type: 'select', label: 'Comparator', options: optionsWithUnknownValue(COMPARATOR_OPTIONS, row?.comparator), columns: 6 },
34
+ { name: 'value_type', type: 'select', label: 'Value type', options: optionsWithUnknownValue(VALUE_TYPE_OPTIONS, row?.value_type), columns: 6, help: row?.value_type === 'bool' ? 'Legacy bool values are visible, but cannot be changed safely until the backend conversion is fixed.' : undefined },
35
+ { name: 'value', type: 'textarea', label: 'Comparison value', required: true }, { name: 'is_required', type: 'switch', label: 'Required condition' },
36
+ ];
37
+ const initial: FormData = row ? { name: row.name ?? '', index: row.index, field_name: row.field_name ?? '', comparator: row.comparator, value_type: row.value_type, value: row.value, is_required: row.is_required === 1 } : { name: '', index: nextIndex, field_name: '', comparator: '==', value_type: 'str', value: '', is_required: false };
38
+ return <div className="modal-pad"><h2 className="modal-title">{creating ? 'Add condition' : 'Edit condition'}</h2><SchemaForm fields={fields} initial={initial} onCancel={() => close(false)} onSubmit={async (data) => { await save.mutateAsync({ id: row?.id ?? null, changes: ruleChanges({ ...data, parent }, creating, row) }); toast.success(creating ? 'Condition added' : 'Condition updated'); close(true); }} /></div>;
39
+ }
40
+ export async function openRuleEditor(parent: number, row?: RuleRow, nextIndex = 0): Promise<boolean> { return (await modal.open<boolean>((close) => <RuleEditor parent={parent} row={row} nextIndex={nextIndex} close={close} />, { size: 'lg' })) ?? false; }
41
+
42
+ function HandlerEditor({ row, close }: { row: RuleSetRow; close: (saved: boolean) => void }) {
43
+ const save = RuleSetModel.useSave(); const [value, setValue] = useState(row.handler ?? ''); const [valid, setValid] = useState(true); const [messages, setMessages] = useState<string[]>([]);
44
+ const changed = value !== (row.handler ?? '');
45
+ const summary = useMemo(() => [...new Set(messages)], [messages]);
46
+ const validationChanged = useCallback((next: boolean, nextMessages: string[]) => { setValid(next); setMessages(nextMessages); }, []);
47
+ return <form className="modal-pad" onSubmit={async (event) => { event.preventDefault(); if (!valid || !changed) return; try { await save.mutateAsync({ id: row.id, changes: { handler: value } }); toast.success('Handler chain updated'); close(true); } catch (error) { toast.error(error instanceof Error ? error.message : 'Handler save failed'); } }}><h2 className="modal-title">Edit handler dispatch chain</h2><HandlerChainBuilder value={value} onChange={setValue} onValidationChange={validationChanged} />{summary.length > 0 && <p className="dim">{summary.length} validation note{summary.length === 1 ? '' : 's'} shown inline. Structural errors must be resolved before save.</p>}<div className="modal-actions"><button type="button" className="btn" onClick={() => close(false)}>Cancel</button><button type="submit" className="btn btn-primary" disabled={!changed || !valid || save.isPending}>{save.isPending ? 'Saving…' : 'Save handler chain'}</button></div></form>;
48
+ }
49
+ export async function openHandlerChainEditor(row: RuleSetRow): Promise<boolean> { return (await modal.open<boolean>((close) => <HandlerEditor row={row} close={close} />, { size: 'lg' })) ?? false; }
50
+
51
+ export async function confirmCatchAllEnable(row: RuleSetRow, conditionCount: number): Promise<boolean> {
52
+ if (conditionCount > 0) return window.confirm(`Enable “${row.name}” in category ${row.category} at priority ${row.priority}?`);
53
+ return window.confirm(`PREEMPTION WARNING\n\n“${row.name}” has zero conditions. Enabling it creates a catch-all for category “${row.category}” at priority ${row.priority}; it can preempt every later active rule set. Enable anyway?`);
54
+ }