web-mojo 2.1.980 → 2.1.1043

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 (62) hide show
  1. package/dist/admin.cjs.js +1 -1
  2. package/dist/admin.cjs.js.map +1 -1
  3. package/dist/admin.es.js +740 -33
  4. package/dist/admin.es.js.map +1 -1
  5. package/dist/auth.cjs.js +1 -1
  6. package/dist/auth.cjs.js.map +1 -1
  7. package/dist/auth.es.js +2 -2
  8. package/dist/auth.es.js.map +1 -1
  9. package/dist/charts.cjs.js +1 -1
  10. package/dist/charts.es.js +2 -2
  11. package/dist/chunks/ChatView-CGBaudUc.js +2 -0
  12. package/dist/chunks/ChatView-CGBaudUc.js.map +1 -0
  13. package/dist/chunks/{ChatView-rAfKBqDw.js → ChatView-DguKw-gR.js} +83 -13
  14. package/dist/chunks/ChatView-DguKw-gR.js.map +1 -0
  15. package/dist/chunks/{Collection-DaTm-2LH.js → Collection-YRfGoT73.js} +2 -2
  16. package/dist/chunks/{Collection-DaTm-2LH.js.map → Collection-YRfGoT73.js.map} +1 -1
  17. package/dist/chunks/{ContextMenu-BuEqfeZS.js → ContextMenu-B4_YS0G8.js} +2 -2
  18. package/dist/chunks/{ContextMenu-BuEqfeZS.js.map → ContextMenu-B4_YS0G8.js.map} +1 -1
  19. package/dist/chunks/{Dialog-CENvQT9n.js → Dialog-BiVgKzSK.js} +3 -3
  20. package/dist/chunks/{Dialog-CENvQT9n.js.map → Dialog-BiVgKzSK.js.map} +1 -1
  21. package/dist/chunks/{Dialog-DZqbxTsP.js → Dialog-DmIPK_Bi.js} +2 -2
  22. package/dist/chunks/{Dialog-DZqbxTsP.js.map → Dialog-DmIPK_Bi.js.map} +1 -1
  23. package/dist/chunks/{FormView-095xPgXv.js → FormView-BClEkzmE.js} +93 -5
  24. package/dist/chunks/FormView-BClEkzmE.js.map +1 -0
  25. package/dist/chunks/FormView-nulck4nL.js +3 -0
  26. package/dist/chunks/FormView-nulck4nL.js.map +1 -0
  27. package/dist/chunks/{ListView-BrsQ26R6.js → ListView-BMNhd5-B.js} +2 -2
  28. package/dist/chunks/{ListView-BrsQ26R6.js.map → ListView-BMNhd5-B.js.map} +1 -1
  29. package/dist/chunks/{MetricsMiniChartWidget-CVRinHn4.js → MetricsMiniChartWidget-CCroU6BZ.js} +2 -2
  30. package/dist/chunks/{MetricsMiniChartWidget-CVRinHn4.js.map → MetricsMiniChartWidget-CCroU6BZ.js.map} +1 -1
  31. package/dist/chunks/{MetricsMiniChartWidget-Dez6aHCT.js → MetricsMiniChartWidget-Esvv-lFp.js} +2 -2
  32. package/dist/chunks/{MetricsMiniChartWidget-Dez6aHCT.js.map → MetricsMiniChartWidget-Esvv-lFp.js.map} +1 -1
  33. package/dist/chunks/{PDFViewer-CwzGbdOv.js → PDFViewer-D4uo3oiA.js} +2 -2
  34. package/dist/chunks/{PDFViewer-CwzGbdOv.js.map → PDFViewer-D4uo3oiA.js.map} +1 -1
  35. package/dist/chunks/{PDFViewer-dAEmy7XJ.js → PDFViewer-NeL91Gon.js} +2 -2
  36. package/dist/chunks/{PDFViewer-dAEmy7XJ.js.map → PDFViewer-NeL91Gon.js.map} +1 -1
  37. package/dist/chunks/{TopNav-Cj9_6vRl.js → TopNav-23B5R-dl.js} +2 -2
  38. package/dist/chunks/{TopNav-Cj9_6vRl.js.map → TopNav-23B5R-dl.js.map} +1 -1
  39. package/dist/chunks/{TopNav-Celew98v.js → TopNav-DC8oGpHp.js} +4 -4
  40. package/dist/chunks/{TopNav-Celew98v.js.map → TopNav-DC8oGpHp.js.map} +1 -1
  41. package/dist/chunks/{WebApp-7JKRzXMJ.js → WebApp-C1vcdSuu.js} +13 -13
  42. package/dist/chunks/WebApp-C1vcdSuu.js.map +1 -0
  43. package/dist/chunks/WebApp-CpxtmTk0.js +2 -0
  44. package/dist/chunks/WebApp-CpxtmTk0.js.map +1 -0
  45. package/dist/docit.cjs.js +1 -1
  46. package/dist/docit.es.js +5 -5
  47. package/dist/index.cjs.js +1 -1
  48. package/dist/index.es.js +11 -11
  49. package/dist/lightbox.cjs.js +1 -1
  50. package/dist/lightbox.es.js +3 -3
  51. package/dist/map.es.js +1 -1
  52. package/dist/timeline.es.js +2 -2
  53. package/package.json +1 -1
  54. package/dist/chunks/ChatView-BXois2IL.js +0 -2
  55. package/dist/chunks/ChatView-BXois2IL.js.map +0 -1
  56. package/dist/chunks/ChatView-rAfKBqDw.js.map +0 -1
  57. package/dist/chunks/FormView-095xPgXv.js.map +0 -1
  58. package/dist/chunks/FormView-DGA3I2IL.js +0 -3
  59. package/dist/chunks/FormView-DGA3I2IL.js.map +0 -1
  60. package/dist/chunks/WebApp-7JKRzXMJ.js.map +0 -1
  61. package/dist/chunks/WebApp-9HUBOegS.js +0 -2
  62. package/dist/chunks/WebApp-9HUBOegS.js.map +0 -1
package/dist/admin.es.js CHANGED
@@ -1,16 +1,16 @@
1
1
  import { P as Page } from "./chunks/Page-Deq4y2Kq.js";
2
- import { V as View, M as MOJOUtils } from "./chunks/Rest-CS4jRCAs.js";
2
+ import { V as View, M as MOJOUtils, r as rest } from "./chunks/Rest-CS4jRCAs.js";
3
3
  import "./chunks/WebSocketClient-D-5DJoMX.js";
4
- import Dialog$1 from "./chunks/Dialog-CENvQT9n.js";
5
- import { M as MetricsChart, c as MetricsMiniChartWidget, P as PieChart } from "./chunks/MetricsMiniChartWidget-CVRinHn4.js";
6
- import { b as TablePage, j as EmailDomainForms, i as EmailDomainList, E as EmailDomain, m as MailboxForms, l as MailboxList, k as Mailbox, q as EmailTemplate, c as TabView, s as EmailTemplateForms, r as EmailTemplateList, I as IncidentEvent, A as IncidentEventForms, z as IncidentEventList, v as FileManagerForms, u as FileManagerList, w as File, T as TableView, y as FileForms, x as FileList, au as GeoLocatedIP, av as GeoLocatedIPList, af as MemberList, ae as LogList, ax as TicketList, D as IncidentList, Y as IncidentStats, O as IncidentHistoryList, N as IncidentHistory, B as Incident, C as ChatView, G as IncidentForms, a2 as Job, a8 as JobEventList, a6 as JobLogList, a4 as JobForms, a3 as JobList, ab as JobRunnerList, a9 as JobsEngineStats, ac as JobRunnerForms, aa as JobRunner, ad as Log, M as Member, ag as MemberForms, ah as MetricsPermission, aj as MetricsForms, ai as MetricsPermissionList, as as PushConfigForms, ap as PushConfigList, ar as PushDeliveryList, al as PushDeviceList, at as PushTemplateForms, an as PushTemplateList, R as RuleSet, _ as MatchByOptions, Z as BundleByOptions, W as RuleList, Q as RuleSetList, h as S3BucketForms, g as S3BucketList, n as SentMessage, o as SentMessageList, az as TicketNoteList, ay as TicketNote, aw as Ticket, aA as TicketForms, aB as TicketCategories } from "./chunks/ChatView-rAfKBqDw.js";
4
+ import Dialog$1 from "./chunks/Dialog-BiVgKzSK.js";
5
+ import { M as MetricsChart, c as MetricsMiniChartWidget, P as PieChart } from "./chunks/MetricsMiniChartWidget-CCroU6BZ.js";
6
+ import { b as TablePage, j as EmailDomainForms, i as EmailDomainList, E as EmailDomain, m as MailboxForms, l as MailboxList, k as Mailbox, q as EmailTemplate, c as TabView, s as EmailTemplateForms, r as EmailTemplateList, I as IncidentEvent, A as IncidentEventForms, z as IncidentEventList, v as FileManagerForms, u as FileManagerList, w as File, T as TableView, y as FileForms, x as FileList, au as GeoLocatedIP, ae as LogList, av as GeoLocatedIPList, af as MemberList, ax as TicketList, D as IncidentList, Y as IncidentStats, O as IncidentHistoryList, N as IncidentHistory, B as Incident, C as ChatView, G as IncidentForms, a2 as Job, a8 as JobEventList, a6 as JobLogList, a4 as JobForms, a3 as JobList, ab as JobRunnerList, a9 as JobsEngineStats, ac as JobRunnerForms, aa as JobRunner, ad as Log, M as Member, ag as MemberForms, ah as MetricsPermission, aj as MetricsForms, ai as MetricsPermissionList, as as PushConfigForms, ap as PushConfigList, ar as PushDeliveryList, al as PushDeviceList, at as PushTemplateForms, an as PushTemplateList, R as RuleSet, _ as MatchByOptions, Z as BundleByOptions, W as RuleList, Q as RuleSetList, h as S3BucketForms, g as S3BucketList, n as SentMessage, o as SentMessageList, az as TicketNoteList, ay as TicketNote, aw as Ticket, aA as TicketForms, aB as TicketCategories } from "./chunks/ChatView-DguKw-gR.js";
7
7
  import DataView from "./chunks/DataView-OUqaLmGB.js";
8
- import { C as ContextMenu, a as Group, G as GroupList, b as GroupForms, f as UserDevice, i as UserDeviceLocationList, g as UserDeviceList, U as User, e as UserDataView, d as UserForms, c as UserList } from "./chunks/ContextMenu-BuEqfeZS.js";
9
- import { C as Collection } from "./chunks/Collection-DaTm-2LH.js";
10
- import { L as LightboxGallery, P as PDFViewer } from "./chunks/PDFViewer-dAEmy7XJ.js";
11
- import { a as applyFileDropMixin, F as FormView } from "./chunks/FormView-095xPgXv.js";
8
+ import { C as ContextMenu, a as Group, G as GroupList, b as GroupForms, f as UserDevice, i as UserDeviceLocationList, g as UserDeviceList, U as User, e as UserDataView, d as UserForms, c as UserList } from "./chunks/ContextMenu-B4_YS0G8.js";
9
+ import { C as Collection, M as Model } from "./chunks/Collection-YRfGoT73.js";
10
+ import { L as LightboxGallery, P as PDFViewer } from "./chunks/PDFViewer-NeL91Gon.js";
11
+ import { a as applyFileDropMixin, F as FormView } from "./chunks/FormView-BClEkzmE.js";
12
12
  import { MapView } from "./map.es.js";
13
- import { B, a, V, b, c, d, W } from "./chunks/WebApp-7JKRzXMJ.js";
13
+ import { B, a, V, b, c, d, W } from "./chunks/WebApp-C1vcdSuu.js";
14
14
  class AdminHeaderView extends View {
15
15
  constructor(options = {}) {
16
16
  super({
@@ -1604,7 +1604,7 @@ class FileTablePage extends TablePage {
1604
1604
  */
1605
1605
  async onActionAdd(event, element) {
1606
1606
  event.preventDefault();
1607
- const Dialog2 = (await import("./chunks/Dialog-CENvQT9n.js")).default;
1607
+ const Dialog2 = (await import("./chunks/Dialog-BiVgKzSK.js")).default;
1608
1608
  const formData = await Dialog2.showForm({
1609
1609
  title: "Upload File",
1610
1610
  size: "md",
@@ -1720,17 +1720,43 @@ class GeoIPView extends View {
1720
1720
  <div>
1721
1721
  <h3 class="mb-1">{{model.ip_address}}</h3>
1722
1722
  <div class="text-muted small">
1723
- {{model.country_name|default('Unknown Location')}}
1723
+ {{model.city|default('Unknown Location')}}, {{model.country_name|default('Unknown Location')}}
1724
1724
  </div>
1725
1725
  <div class="text-muted small mt-1">
1726
- Provider: {{model.provider|capitalize}}
1726
+ ISP: {{model.isp|capitalize}}
1727
1727
  </div>
1728
1728
  </div>
1729
1729
  </div>
1730
1730
 
1731
- <!-- Right Side: Actions -->
1732
- <div class="d-flex align-items-center gap-4">
1733
- <div data-container="geoip-context-menu"></div>
1731
+ <!-- Right Side: Risk Summary + Actions -->
1732
+ <div class="d-flex align-items-start gap-4">
1733
+ <!-- Risk summary -->
1734
+ <div class="text-end">
1735
+ <div class="d-flex align-items-baseline justify-content-end gap-2">
1736
+ <span class="text-muted">Risk:</span>
1737
+ <span class="fw-bold fs-4
1738
+ {{#model.is_threat}} text-danger {{/model.is_threat}}
1739
+ {{#model.is_suspicious}} text-warning {{/model.is_suspicious}}
1740
+ {{^model.is_threat}}{{^model.is_suspicious}} text-success {{/model.is_suspicious}}{{/model.is_threat}}
1741
+ ">{{#model.threat_level}}{{model.threat_level|capitalize}}{{/model.threat_level}}{{^model.threat_level}}Unknown{{/model.threat_level}}</span>
1742
+ </div>
1743
+ <div class="mt-1 small d-flex align-items-center justify-content-end gap-2">
1744
+ <span class="text-muted">Score:</span>
1745
+ <span class="fw-semibold">{{model.risk_score|default('—')}}</span>
1746
+ </div>
1747
+ <div class="mt-1 d-flex align-items-center justify-content-end gap-2">
1748
+ <i class="bi bi-shield-lock {{#model.is_tor}}fs-4 text-success{{/model.is_tor}}{{^model.is_tor}}text-muted{{/model.is_tor}}" data-bs-toggle="tooltip" title="TOR exit"></i>
1749
+ <i class="bi bi-shield {{#model.is_vpn}}fs-4 text-success{{/model.is_vpn}}{{^model.is_vpn}}text-muted{{/model.is_vpn}}" data-bs-toggle="tooltip" title="VPN detected"></i>
1750
+ <i class="bi bi-cloud {{#model.is_cloud}}fs-4 text-success{{/model.is_cloud}}{{^model.is_cloud}}text-muted{{/model.is_cloud}}" data-bs-toggle="tooltip" title="Cloud provider"></i>
1751
+ <i class="bi bi-hdd-stack {{#model.is_datacenter}}fs-4 text-success{{/model.is_datacenter}}{{^model.is_datacenter}}text-muted{{/model.is_datacenter}}" data-bs-toggle="tooltip" title="Datacenter"></i>
1752
+ <i class="bi bi-phone {{#model.is_mobile}}fs-4 text-success{{/model.is_mobile}}{{^model.is_mobile}}text-muted{{/model.is_mobile}}" data-bs-toggle="tooltip" title="Mobile connection"></i>
1753
+ <i class="bi bi-diagram-3 {{#model.is_proxy}}fs-4 text-success{{/model.is_proxy}}{{^model.is_proxy}}text-muted{{/model.is_proxy}}" data-bs-toggle="tooltip" title="Proxy"></i>
1754
+ </div>
1755
+ </div>
1756
+ <!-- Actions: context menu aligned to top (not vertically centered) -->
1757
+ <div class="d-flex align-items-start">
1758
+ <div data-container="geoip-context-menu"></div>
1759
+ </div>
1734
1760
  </div>
1735
1761
  </div>
1736
1762
 
@@ -1759,25 +1785,41 @@ class GeoIPView extends View {
1759
1785
  { name: "longitude", label: "Longitude", cols: 4 }
1760
1786
  ]
1761
1787
  });
1762
- this.securityView = new DataView({
1788
+ this.networkView = new DataView({
1763
1789
  model: this.model,
1764
1790
  className: "p-3",
1765
1791
  showEmptyValues: true,
1766
1792
  emptyValueText: "—",
1767
1793
  columns: 2,
1768
1794
  fields: [
1769
- { name: "threat_level", label: "Threat Level", cols: 4 },
1770
- { name: "is_tor", label: "TOR Exit Node", cols: 4 },
1795
+ { name: "is_tor", label: "TOR Exit Node", formatter: "yesnoicon", cols: 4 },
1771
1796
  { name: "is_vpn", label: "VPN", formatter: "yesnoicon", cols: 4 },
1772
1797
  { name: "is_proxy", label: "Proxy", formatter: "yesnoicon", cols: 4 },
1773
1798
  { name: "is_cloud", label: "Cloud Provider", formatter: "yesnoicon", cols: 4 },
1774
1799
  { name: "is_datacenter", label: "Datacenter", formatter: "yesnoicon", cols: 4 },
1800
+ { name: "is_mobile", label: "Mobile", formatter: "yesnoicon", cols: 4 },
1801
+ { name: "mobile_carrier", label: "Mobile Carrier", cols: 8 },
1775
1802
  { name: "asn", label: "ASN", cols: 4 },
1776
- { name: "asn_org", label: "ASN Organization", cols: 4 },
1777
- { name: "isp", label: "ISP", cols: 4 },
1803
+ { name: "asn_org", label: "ASN Organization", cols: 8 },
1804
+ { name: "isp", label: "ISP", cols: 12 },
1778
1805
  { name: "connection_type", label: "Connection Type", cols: 6 }
1779
1806
  ]
1780
1807
  });
1808
+ this.riskView = new DataView({
1809
+ model: this.model,
1810
+ className: "p-3",
1811
+ showEmptyValues: true,
1812
+ emptyValueText: "—",
1813
+ columns: 2,
1814
+ fields: [
1815
+ { name: "threat_level", label: "Threat Level", cols: 6 },
1816
+ { name: "risk_score", label: "Risk Score", cols: 6 },
1817
+ { name: "is_threat", label: "Threat", formatter: "yesnoicon", cols: 6 },
1818
+ { name: "is_suspicious", label: "Suspicious", formatter: "yesnoicon", cols: 6 },
1819
+ { name: "is_known_attacker", label: "Known Attacker", formatter: "yesnoicon", cols: 6 },
1820
+ { name: "is_known_abuser", label: "Known Abuser", formatter: "yesnoicon", cols: 6 }
1821
+ ]
1822
+ });
1781
1823
  this.metadataView = new DataView({
1782
1824
  model: this.model,
1783
1825
  className: "p-3",
@@ -1789,12 +1831,77 @@ class GeoIPView extends View {
1789
1831
  { name: "provider", label: "Data Provider", formatter: "capitalize", cols: 6 },
1790
1832
  { name: "created", label: "Created", formatter: "datetime", cols: 6 },
1791
1833
  { name: "modified", label: "Last Modified", formatter: "datetime", cols: 6 },
1792
- { name: "expires_at", label: "Expires", formatter: "datetime", cols: 12 }
1834
+ { name: "last_seen", label: "Last Seen", formatter: "datetime", cols: 6 },
1835
+ { name: "expires_at", label: "Expires", formatter: "datetime", cols: 6 }
1836
+ ]
1837
+ });
1838
+ const eventsCollection = new IncidentEventList({
1839
+ params: {
1840
+ size: 5,
1841
+ source_ip: this.model.get("ip_address")
1842
+ }
1843
+ });
1844
+ this.eventsView = new TableView({
1845
+ collection: eventsCollection,
1846
+ hideActivePillNames: ["source_ip"],
1847
+ columns: [
1848
+ { key: "id", label: "ID", sortable: true, width: "40px" },
1849
+ { key: "created", label: "Date", formatter: "datetime", sortable: true, width: "150px" },
1850
+ { key: "category|badge", label: "Category" },
1851
+ { key: "title", label: "Title" }
1852
+ ]
1853
+ });
1854
+ const logsCollection = new LogList({
1855
+ params: {
1856
+ size: 5,
1857
+ ip: this.model.get("ip_address")
1858
+ }
1859
+ });
1860
+ this.logsView = new TableView({
1861
+ collection: logsCollection,
1862
+ permissions: "view_logs",
1863
+ hideActivePillNames: ["ip"],
1864
+ columns: [
1865
+ {
1866
+ key: "created",
1867
+ label: "Timestamp",
1868
+ sortable: true,
1869
+ formatter: "epoch|datetime",
1870
+ filter: {
1871
+ name: "created",
1872
+ type: "daterange",
1873
+ startName: "dr_start",
1874
+ endName: "dr_end",
1875
+ fieldName: "dr_field",
1876
+ label: "Date Range",
1877
+ format: "YYYY-MM-DD",
1878
+ displayFormat: "MMM DD, YYYY",
1879
+ separator: " to "
1880
+ }
1881
+ },
1882
+ {
1883
+ key: "level",
1884
+ label: "Level",
1885
+ sortable: true,
1886
+ filter: {
1887
+ type: "select",
1888
+ options: [
1889
+ { value: "info", label: "Info" },
1890
+ { value: "warning", label: "Warning" },
1891
+ { value: "error", label: "Error" }
1892
+ ]
1893
+ }
1894
+ },
1895
+ { key: "kind", label: "Kind", filter: { type: "text" } },
1896
+ { name: "log", label: "Log" }
1793
1897
  ]
1794
1898
  });
1795
1899
  const tabs = {
1796
1900
  "Location": this.detailsView,
1797
- "Security": this.securityView,
1901
+ "Network": this.networkView,
1902
+ "Risk & Reputation": this.riskView,
1903
+ "Events": this.eventsView,
1904
+ "Logs": this.logsView,
1798
1905
  "Metadata": this.metadataView
1799
1906
  };
1800
1907
  if (this.hasCoordinates) {
@@ -1851,6 +1958,19 @@ class GeoIPView extends View {
1851
1958
  });
1852
1959
  this.addChild(geoIPMenu);
1853
1960
  }
1961
+ async onAfterRender() {
1962
+ await super.onAfterRender();
1963
+ if (window.bootstrap && window.bootstrap.Tooltip && this.element) {
1964
+ const tooltipTriggerList = this.element.querySelectorAll('[data-bs-toggle="tooltip"]');
1965
+ tooltipTriggerList.forEach((el) => {
1966
+ const existing = window.bootstrap.Tooltip.getInstance(el);
1967
+ if (existing && typeof existing.dispose === "function") {
1968
+ existing.dispose();
1969
+ }
1970
+ new window.bootstrap.Tooltip(el);
1971
+ });
1972
+ }
1973
+ }
1854
1974
  async onActionEditLocation() {
1855
1975
  const resp = await Dialog$1.showModelForm({
1856
1976
  title: `Edit Location - ${this.model.get("ip_address")}`,
@@ -1888,6 +2008,10 @@ class GeoIPView extends View {
1888
2008
  await this.model.save({ refresh: true });
1889
2009
  this.getApp()?.toast?.info("Refresh request sent for " + this.model.get("ip_address"));
1890
2010
  }
2011
+ async onActionThreatAnalysis() {
2012
+ await this.model.save({ threat_analysis: true });
2013
+ this.getApp()?.toast?.info("Requesting threat analysis for " + this.model.get("ip_address"));
2014
+ }
1891
2015
  async onActionViewOnMap() {
1892
2016
  if (this.hasCoordinates) {
1893
2017
  const lat = this.model.get("latitude");
@@ -1961,7 +2085,7 @@ class GeoLocatedIPTablePage extends TablePage {
1961
2085
  clickAction: "view",
1962
2086
  // Toolbar
1963
2087
  showRefresh: true,
1964
- showAdd: false,
2088
+ showAdd: true,
1965
2089
  showExport: true,
1966
2090
  // Empty state
1967
2091
  emptyMessage: "No GeoIP records found.",
@@ -1971,9 +2095,34 @@ class GeoLocatedIPTablePage extends TablePage {
1971
2095
  bordered: false,
1972
2096
  hover: true,
1973
2097
  responsive: false
2098
+ },
2099
+ tableViewOptions: {
2100
+ addButtonLabel: "Lookup IP",
2101
+ onAdd: (evt) => {
2102
+ evt.preventDefault();
2103
+ this.onLookup();
2104
+ }
1974
2105
  }
1975
2106
  });
1976
2107
  }
2108
+ async onLookup() {
2109
+ const data = await this.getApp().showForm({
2110
+ title: "Lookup IP",
2111
+ fields: [
2112
+ {
2113
+ name: "ip",
2114
+ type: "text",
2115
+ required: true
2116
+ }
2117
+ ]
2118
+ });
2119
+ if (data && data.ip) {
2120
+ const model = await GeoLocatedIP.lookup(data.ip);
2121
+ if (model) {
2122
+ this.tableView._onRowView({ model });
2123
+ }
2124
+ }
2125
+ }
1977
2126
  }
1978
2127
  class GroupView extends View {
1979
2128
  constructor(options = {}) {
@@ -2194,8 +2343,10 @@ class GroupView extends View {
2194
2343
  Collection: GroupList,
2195
2344
  labelField: "name",
2196
2345
  itemTemplate: `
2197
- <div class="fs-5">{{model.name}}</div>
2346
+ <div class="ms-2">
2347
+ <div class="fs-7">{{model.name}}</div>
2198
2348
  <div class="fs-8 text-muted">{{model.kind}}</div>
2349
+ </div>
2199
2350
  `,
2200
2351
  valueField: "id",
2201
2352
  enableSearch: true,
@@ -2329,15 +2480,7 @@ class GroupTablePage extends TablePage {
2329
2480
  striped: true,
2330
2481
  bordered: false,
2331
2482
  hover: true,
2332
- responsive: false,
2333
- toolbarButtons: [
2334
- {
2335
- label: "Add Multiple",
2336
- icon: "bi bi-plus-circle",
2337
- action: "add-multiple",
2338
- className: "btn-success"
2339
- }
2340
- ]
2483
+ responsive: false
2341
2484
  }
2342
2485
  });
2343
2486
  }
@@ -8474,6 +8617,548 @@ class UserTablePage extends TablePage {
8474
8617
  return false;
8475
8618
  }
8476
8619
  }
8620
+ class PhoneNumber extends Model {
8621
+ constructor(data = {}, options = {}) {
8622
+ super(data, {
8623
+ endpoint: "/api/phonehub/number",
8624
+ ...options
8625
+ });
8626
+ }
8627
+ /**
8628
+ * Normalize an arbitrary phone_number string to E.164 format.
8629
+ * @param {string} phoneNumber - Raw phone number input.
8630
+ * @param {string} [countryCode='US'] - Optional country code (default US).
8631
+ * @returns {Promise<{success: boolean, phone_number?: string, data?: object, error?: string, response?: any}>}
8632
+ */
8633
+ static async normalize(phoneNumber, countryCode = "US") {
8634
+ const url = "/api/phonehub/number/normalize";
8635
+ const payload = {
8636
+ phone_number: phoneNumber
8637
+ };
8638
+ if (countryCode) payload.country_code = countryCode;
8639
+ const resp = await rest.POST(url, payload);
8640
+ const body = resp?.data ?? resp;
8641
+ const ok = body?.status === true || body?.success === true;
8642
+ if (ok) {
8643
+ const normalized = body?.data?.phone_number ?? body?.phone_number;
8644
+ return { success: true, phone_number: normalized, data: body?.data ?? body, response: resp };
8645
+ }
8646
+ return { success: false, error: body?.error || "Normalization failed", response: resp };
8647
+ }
8648
+ /**
8649
+ * Lookup phone number details (carrier, line_type, owner, etc.)
8650
+ * @param {string} phoneNumber - E.164 or raw phone number.
8651
+ * @param {object} [options] - { force_refresh?: boolean, group?: number }
8652
+ * @returns {Promise<{success: boolean, model?: PhoneNumber, data?: object, error?: string, response?: any}>}
8653
+ */
8654
+ static async lookup(phoneNumber, options = {}) {
8655
+ const url = "/api/phonehub/number/lookup";
8656
+ const resp = await rest.POST(url, {
8657
+ phone_number: phoneNumber,
8658
+ ...options
8659
+ });
8660
+ const body = resp?.data ?? resp;
8661
+ const ok = body?.status === true || body?.success === true;
8662
+ if (ok) {
8663
+ const data = body?.data ?? {};
8664
+ const model = new PhoneNumber(data, { endpoint: "/api/phonehub/number" });
8665
+ return { success: true, model, data, response: resp };
8666
+ }
8667
+ return { success: false, error: body?.error || "Phone lookup failed", response: resp };
8668
+ }
8669
+ }
8670
+ class PhoneNumberList extends Collection {
8671
+ constructor(options = {}) {
8672
+ super({
8673
+ ModelClass: PhoneNumber,
8674
+ endpoint: "/api/phonehub/number",
8675
+ size: 10,
8676
+ ...options
8677
+ });
8678
+ }
8679
+ }
8680
+ class SMS extends Model {
8681
+ constructor(data = {}, options = {}) {
8682
+ super(data, {
8683
+ endpoint: "/api/phonehub/sms",
8684
+ ...options
8685
+ });
8686
+ }
8687
+ /**
8688
+ * Send SMS via PhoneHub (Twilio under the hood).
8689
+ * @param {object} params - { to_number, body, from_number?, group?, metadata? }
8690
+ * @returns {Promise<{success: boolean, model?: SMS, data?: object, error?: string, response?: any}>}
8691
+ */
8692
+ static async send(params = {}) {
8693
+ const url = "/api/phonehub/sms/send";
8694
+ const resp = await rest.POST(url, params);
8695
+ const body = resp?.data ?? resp;
8696
+ const ok = body?.status === true || body?.success === true;
8697
+ if (ok) {
8698
+ const data = body?.data ?? {};
8699
+ const model = new SMS(data, { endpoint: "/api/phonehub/sms" });
8700
+ return { success: true, model, data, response: resp };
8701
+ }
8702
+ return { success: false, error: body?.error || "Failed to send SMS", response: resp };
8703
+ }
8704
+ }
8705
+ class SMSList extends Collection {
8706
+ constructor(options = {}) {
8707
+ super({
8708
+ ModelClass: SMS,
8709
+ endpoint: "/api/phonehub/sms",
8710
+ size: 10,
8711
+ ...options
8712
+ });
8713
+ }
8714
+ }
8715
+ class PhoneNumberView extends View {
8716
+ constructor(options = {}) {
8717
+ super({
8718
+ className: "phone-number-view",
8719
+ ...options
8720
+ });
8721
+ this.model = options.model || new PhoneNumber(options.data || {});
8722
+ this.template = `
8723
+ <div class="phone-number-view-container">
8724
+ <!-- Header -->
8725
+ <div class="d-flex justify-content-between align-items-center mb-4">
8726
+ <!-- Left Side: Icon & Info -->
8727
+ <div class="d-flex align-items-center gap-3">
8728
+ <div class="fs-1 text-primary">
8729
+ <i class="bi bi-telephone"></i>
8730
+ </div>
8731
+ <div>
8732
+ <h3 class="mb-1">{{model.phone_number|default('Unknown Number')}}</h3>
8733
+ <div class="text-muted small">
8734
+ {{model.carrier|default('—')}} {{#model.line_type}}· {{model.line_type|capitalize}}{{/model.line_type}}
8735
+ </div>
8736
+ <div class="text-muted small mt-1">
8737
+ {{#model.country_code}}Country: {{model.country_code}}{{/model.country_code}}
8738
+ {{#model.region}} · Region: {{model.region}}{{/model.region}}
8739
+ {{#model.state}} · State: {{model.state}}{{/model.state}}
8740
+ </div>
8741
+ </div>
8742
+ </div>
8743
+
8744
+ <!-- Right Side: Actions -->
8745
+ <div class="d-flex align-items-center gap-4">
8746
+ <div data-container="phone-context-menu"></div>
8747
+ </div>
8748
+ </div>
8749
+
8750
+ <!-- Tabs -->
8751
+ <div data-container="phone-tabs"></div>
8752
+ </div>
8753
+ `;
8754
+ }
8755
+ async onInit() {
8756
+ this.overviewView = new DataView({
8757
+ model: this.model,
8758
+ className: "p-3",
8759
+ showEmptyValues: true,
8760
+ emptyValueText: "—",
8761
+ columns: 2,
8762
+ fields: [
8763
+ { name: "phone_number", label: "Phone Number", cols: 6 },
8764
+ { name: "country_code", label: "Country Code", cols: 6 },
8765
+ { name: "region", label: "Region", cols: 6 },
8766
+ { name: "state", label: "State", cols: 6 },
8767
+ { name: "registered_owner", label: "Registered Owner", cols: 6 },
8768
+ { name: "owner_type", label: "Owner Type", formatter: "capitalize", cols: 6 },
8769
+ { name: "is_valid", label: "Valid", formatter: "yesnoicon", cols: 4 },
8770
+ { name: "is_mobile", label: "Mobile", formatter: "yesnoicon", cols: 4 },
8771
+ { name: "is_voip", label: "VOIP", formatter: "yesnoicon", cols: 4 }
8772
+ ]
8773
+ });
8774
+ this.carrierView = new DataView({
8775
+ model: this.model,
8776
+ className: "p-3",
8777
+ showEmptyValues: true,
8778
+ emptyValueText: "—",
8779
+ columns: 2,
8780
+ fields: [
8781
+ { name: "carrier", label: "Carrier", cols: 6 },
8782
+ { name: "line_type", label: "Line Type", formatter: "capitalize", cols: 6 },
8783
+ { name: "lookup_provider", label: "Lookup Provider", formatter: "capitalize", cols: 6 },
8784
+ { name: "lookup_count", label: "Lookup Count", cols: 6 },
8785
+ { name: "last_lookup_at", label: "Last Lookup", formatter: "datetime", cols: 6 },
8786
+ { name: "lookup_expires_at", label: "Cache Expires", formatter: "datetime", cols: 6 }
8787
+ ]
8788
+ });
8789
+ this.addressView = new DataView({
8790
+ model: this.model,
8791
+ className: "p-3",
8792
+ showEmptyValues: true,
8793
+ emptyValueText: "—",
8794
+ columns: 2,
8795
+ fields: [
8796
+ { name: "address_line1", label: "Address Line 1", cols: 12 },
8797
+ { name: "address_city", label: "City", cols: 4 },
8798
+ { name: "address_state", label: "State", cols: 4 },
8799
+ { name: "address_zip", label: "ZIP", cols: 4 },
8800
+ { name: "address_country", label: "Country", cols: 6 }
8801
+ ]
8802
+ });
8803
+ this.metadataView = new DataView({
8804
+ model: this.model,
8805
+ className: "p-3",
8806
+ showEmptyValues: true,
8807
+ emptyValueText: "—",
8808
+ columns: 2,
8809
+ fields: [
8810
+ { name: "id", label: "Record ID", cols: 6 },
8811
+ { name: "created", label: "Created", formatter: "datetime", cols: 6 },
8812
+ { name: "modified", label: "Last Modified", formatter: "datetime", cols: 6 }
8813
+ ]
8814
+ });
8815
+ const tabs = {
8816
+ "Overview": this.overviewView,
8817
+ "Carrier": this.carrierView,
8818
+ "Address": this.addressView,
8819
+ "Metadata": this.metadataView
8820
+ };
8821
+ this.tabView = new TabView({
8822
+ containerId: "phone-tabs",
8823
+ tabs,
8824
+ activeTab: "Overview"
8825
+ });
8826
+ this.addChild(this.tabView);
8827
+ const menuItems = [
8828
+ { label: "Refresh Lookup", action: "refresh-lookup", icon: "bi-arrow-repeat" },
8829
+ { type: "divider" },
8830
+ { label: "Delete Record", action: "delete-phone", icon: "bi-trash", danger: true }
8831
+ ];
8832
+ const ctxMenu = new ContextMenu({
8833
+ containerId: "phone-context-menu",
8834
+ className: "context-menu-view header-menu-absolute",
8835
+ context: this.model,
8836
+ config: {
8837
+ icon: "bi-three-dots-vertical",
8838
+ items: menuItems
8839
+ }
8840
+ });
8841
+ this.addChild(ctxMenu);
8842
+ }
8843
+ // Actions
8844
+ async onActionRefreshLookup() {
8845
+ const number = this.model.get("phone_number");
8846
+ if (!number) {
8847
+ this.getApp()?.toast?.warning?.("No phone number to lookup");
8848
+ return;
8849
+ }
8850
+ try {
8851
+ this.getApp()?.toast?.info?.("Refreshing lookup...");
8852
+ const resp = await PhoneNumber.lookup(number, { force_refresh: true });
8853
+ if (resp.success && resp.data) {
8854
+ this.model.set(resp.data);
8855
+ await this.render();
8856
+ this.getApp()?.toast?.success?.("Lookup refreshed");
8857
+ } else {
8858
+ const msg = resp.error || "Lookup failed";
8859
+ this.getApp()?.toast?.error?.(msg);
8860
+ }
8861
+ } catch (e) {
8862
+ this.getApp()?.toast?.error?.(e.message || "Lookup failed");
8863
+ }
8864
+ }
8865
+ async onActionDeletePhone() {
8866
+ const confirmed = await Dialog$1.confirm(
8867
+ `Are you sure you want to delete the record for "${this.model.get("phone_number") || "this number"}"?`,
8868
+ "Confirm Deletion",
8869
+ { confirmClass: "btn-danger", confirmText: "Delete" }
8870
+ );
8871
+ if (!confirmed) return;
8872
+ try {
8873
+ const resp = await this.model.destroy();
8874
+ if (resp?.success) {
8875
+ this.emit("phone:deleted", { model: this.model });
8876
+ } else {
8877
+ this.getApp()?.toast?.error?.("Delete failed");
8878
+ }
8879
+ } catch (e) {
8880
+ this.getApp()?.toast?.error?.(e.message || "Delete failed");
8881
+ }
8882
+ }
8883
+ }
8884
+ PhoneNumberView.MODEL_CLASS = PhoneNumber;
8885
+ class PhoneNumberTablePage extends TablePage {
8886
+ constructor(options = {}) {
8887
+ super({
8888
+ ...options,
8889
+ // Identity
8890
+ name: "admin_phonehub_numbers",
8891
+ pageName: "Phone Numbers",
8892
+ router: "admin/phonehub/numbers",
8893
+ // Data source
8894
+ Collection: PhoneNumberList,
8895
+ // Item view configuration
8896
+ itemView: PhoneNumberView,
8897
+ viewDialogOptions: {
8898
+ header: false
8899
+ // size: 'xl'
8900
+ },
8901
+ // Column definitions
8902
+ columns: [
8903
+ { key: "phone_number", label: "Phone Number", sortable: true },
8904
+ { key: "carrier", label: "Carrier", sortable: true, formatter: "default('—')" },
8905
+ { key: "line_type", label: "Line Type", sortable: true, formatter: "capitalize" },
8906
+ { key: "is_mobile", label: "Mobile", formatter: "yesnoicon" },
8907
+ { key: "is_voip", label: "VOIP", formatter: "yesnoicon" },
8908
+ { key: "is_valid", label: "Valid", formatter: "yesnoicon" },
8909
+ { key: "registered_owner", label: "Owner", sortable: true, formatter: "default('—')" },
8910
+ { key: "owner_type", label: "Owner Type", formatter: "capitalize" },
8911
+ { key: "last_lookup_at|relative", label: "Last Lookup", sortable: true }
8912
+ ],
8913
+ // Table features
8914
+ selectable: true,
8915
+ searchable: true,
8916
+ sortable: true,
8917
+ filterable: true,
8918
+ paginated: true,
8919
+ // Row action
8920
+ clickAction: "view",
8921
+ // Toolbar
8922
+ showRefresh: true,
8923
+ showAdd: true,
8924
+ showExport: true,
8925
+ // Empty state
8926
+ emptyMessage: "No phone numbers found.",
8927
+ // Table display options
8928
+ tableOptions: {
8929
+ striped: true,
8930
+ bordered: false,
8931
+ hover: true,
8932
+ responsive: false
8933
+ },
8934
+ tableViewOptions: {
8935
+ addButtonLabel: "Lookup",
8936
+ addButtonIcon: "bi-search",
8937
+ onAdd: (evt) => {
8938
+ evt.preventDefault();
8939
+ this.onLookup();
8940
+ }
8941
+ }
8942
+ });
8943
+ }
8944
+ async onLookup() {
8945
+ const data = await this.getApp().showForm({
8946
+ title: "Lookup Phone Number",
8947
+ fields: [
8948
+ {
8949
+ name: "number",
8950
+ type: "text",
8951
+ required: true
8952
+ }
8953
+ ]
8954
+ });
8955
+ if (data && data.number) {
8956
+ const resp = await PhoneNumber.lookup(data.number);
8957
+ if (resp.model) {
8958
+ this.tableView._onRowView(resp);
8959
+ }
8960
+ }
8961
+ }
8962
+ }
8963
+ class SMSView extends View {
8964
+ constructor(options = {}) {
8965
+ super({
8966
+ className: "sms-view",
8967
+ ...options
8968
+ });
8969
+ this.model = options.model || new SMS(options.data || {});
8970
+ this.template = `
8971
+ <div class="sms-view-container">
8972
+ <!-- Header -->
8973
+ <div class="d-flex justify-content-between align-items-center mb-4">
8974
+ <!-- Left Side: Icon & Info -->
8975
+ <div class="d-flex align-items-center gap-3">
8976
+ <div class="fs-1 text-primary">
8977
+ <i class="bi bi-chat-dots"></i>
8978
+ </div>
8979
+ <div>
8980
+ <h3 class="mb-1">
8981
+ {{#model.direction}}{{model.direction|capitalize}}{{/model.direction}}
8982
+ {{^model.direction}}Message{{/model.direction}}
8983
+ <small class="text-muted ms-2">
8984
+ {{#model.status}}[{{model.status|capitalize}}]{{/model.status}}
8985
+ </small>
8986
+ </h3>
8987
+ <div class="text-muted small">
8988
+ {{#model.from_number}}From: {{model.from_number}}{{/model.from_number}}
8989
+ {{#model.to_number}} · To: {{model.to_number}}{{/model.to_number}}
8990
+ </div>
8991
+ <div class="text-muted small mt-1">
8992
+ {{#model.provider}}Provider: {{model.provider|capitalize}}{{/model.provider}}
8993
+ {{#model.provider_message_id}} · SID: {{model.provider_message_id}}{{/model.provider_message_id}}
8994
+ </div>
8995
+ </div>
8996
+ </div>
8997
+
8998
+ <!-- Right Side: Actions -->
8999
+ <div class="d-flex align-items-center gap-4">
9000
+ <div data-container="sms-context-menu"></div>
9001
+ </div>
9002
+ </div>
9003
+
9004
+ <!-- Tabs -->
9005
+ <div data-container="sms-tabs"></div>
9006
+ </div>
9007
+ `;
9008
+ }
9009
+ async onInit() {
9010
+ this.messageView = new DataView({
9011
+ model: this.model,
9012
+ className: "p-3",
9013
+ showEmptyValues: true,
9014
+ emptyValueText: "—",
9015
+ columns: 2,
9016
+ fields: [
9017
+ { name: "direction", label: "Direction", formatter: "capitalize", cols: 4 },
9018
+ { name: "status", label: "Status", formatter: "capitalize", cols: 4 },
9019
+ { name: "from_number", label: "From", cols: 6 },
9020
+ { name: "to_number", label: "To", cols: 6 },
9021
+ { name: "body", label: "Message Body", cols: 12 }
9022
+ ]
9023
+ });
9024
+ this.deliveryView = new DataView({
9025
+ model: this.model,
9026
+ className: "p-3",
9027
+ showEmptyValues: true,
9028
+ emptyValueText: "—",
9029
+ columns: 2,
9030
+ fields: [
9031
+ { name: "provider", label: "Provider", formatter: "capitalize", cols: 6 },
9032
+ { name: "provider_message_id", label: "Provider Message ID", cols: 6 },
9033
+ { name: "sent_at", label: "Sent At", formatter: "datetime", cols: 6 },
9034
+ { name: "delivered_at", label: "Delivered At", formatter: "datetime", cols: 6 },
9035
+ { name: "error_code", label: "Error Code", cols: 6 },
9036
+ { name: "error_message", label: "Error Message", cols: 12 }
9037
+ ]
9038
+ });
9039
+ this.metadataView = new DataView({
9040
+ model: this.model,
9041
+ className: "p-3",
9042
+ showEmptyValues: true,
9043
+ emptyValueText: "—",
9044
+ columns: 2,
9045
+ fields: [
9046
+ { name: "id", label: "Record ID", cols: 6 },
9047
+ { name: "created", label: "Created", formatter: "datetime", cols: 6 },
9048
+ { name: "modified", label: "Last Modified", formatter: "datetime", cols: 6 }
9049
+ ]
9050
+ });
9051
+ const tabs = {
9052
+ "Message": this.messageView,
9053
+ "Delivery": this.deliveryView,
9054
+ "Metadata": this.metadataView
9055
+ };
9056
+ this.tabView = new TabView({
9057
+ containerId: "sms-tabs",
9058
+ tabs,
9059
+ activeTab: "Message"
9060
+ });
9061
+ this.addChild(this.tabView);
9062
+ const menuItems = [
9063
+ { label: "Refresh", action: "refresh-sms", icon: "bi-arrow-repeat" },
9064
+ { type: "divider" },
9065
+ { label: "Delete Message", action: "delete-sms", icon: "bi-trash", danger: true }
9066
+ ];
9067
+ const ctxMenu = new ContextMenu({
9068
+ containerId: "sms-context-menu",
9069
+ className: "context-menu-view header-menu-absolute",
9070
+ context: this.model,
9071
+ config: {
9072
+ icon: "bi-three-dots-vertical",
9073
+ items: menuItems
9074
+ }
9075
+ });
9076
+ this.addChild(ctxMenu);
9077
+ }
9078
+ // Actions
9079
+ async onActionRefreshSms() {
9080
+ try {
9081
+ this.getApp()?.toast?.info?.("Refreshing message...");
9082
+ await this.model.fetch();
9083
+ await this.render();
9084
+ this.getApp()?.toast?.success?.("Message refreshed");
9085
+ } catch (e) {
9086
+ this.getApp()?.toast?.error?.(e.message || "Refresh failed");
9087
+ }
9088
+ }
9089
+ async onActionDeleteSms() {
9090
+ const title = "Confirm Deletion";
9091
+ const msg = `Are you sure you want to delete this message?`;
9092
+ const confirmed = await Dialog$1.confirm(msg, title, {
9093
+ confirmClass: "btn-danger",
9094
+ confirmText: "Delete"
9095
+ });
9096
+ if (!confirmed) return;
9097
+ try {
9098
+ const resp = await this.model.destroy();
9099
+ if (resp?.success) {
9100
+ this.emit("sms:deleted", { model: this.model });
9101
+ } else {
9102
+ this.getApp()?.toast?.error?.("Delete failed");
9103
+ }
9104
+ } catch (e) {
9105
+ this.getApp()?.toast?.error?.(e.message || "Delete failed");
9106
+ }
9107
+ }
9108
+ }
9109
+ SMSView.MODEL_CLASS = SMS;
9110
+ class SMSTablePage extends TablePage {
9111
+ constructor(options = {}) {
9112
+ super({
9113
+ ...options,
9114
+ // Identity
9115
+ name: "admin_phonehub_sms",
9116
+ pageName: "SMS Messages",
9117
+ router: "admin/phonehub/sms",
9118
+ // Data source
9119
+ Collection: SMSList,
9120
+ // Item view configuration
9121
+ itemView: SMSView,
9122
+ viewDialogOptions: {
9123
+ header: false,
9124
+ size: "xl"
9125
+ },
9126
+ // Column definitions
9127
+ columns: [
9128
+ { key: "direction", label: "Direction", sortable: true },
9129
+ { key: "from_number", label: "From", sortable: true, formatter: "default('—')" },
9130
+ { key: "to_number", label: "To", sortable: true, formatter: "default('—')" },
9131
+ { key: "status", label: "Status", sortable: true },
9132
+ { key: "provider", label: "Provider", sortable: true, formatter: "default('—')" },
9133
+ { key: "body", label: "Message", formatter: "default('—')" },
9134
+ { key: "sent_at", label: "Sent At", sortable: true, formatter: "datetime" },
9135
+ { key: "delivered_at", label: "Delivered At", sortable: true, formatter: "datetime" },
9136
+ { key: "created", label: "Created", sortable: true, formatter: "datetime" }
9137
+ ],
9138
+ // Table features
9139
+ selectable: true,
9140
+ searchable: true,
9141
+ sortable: true,
9142
+ filterable: true,
9143
+ paginated: true,
9144
+ // Row action
9145
+ clickAction: "view",
9146
+ // Toolbar
9147
+ showRefresh: true,
9148
+ showAdd: false,
9149
+ showExport: true,
9150
+ // Empty state
9151
+ emptyMessage: "No SMS messages found.",
9152
+ // Table display options
9153
+ tableOptions: {
9154
+ striped: true,
9155
+ bordered: false,
9156
+ hover: true,
9157
+ responsive: false
9158
+ }
9159
+ });
9160
+ }
9161
+ }
8477
9162
  function registerSystemPages(app, addToMenu = true) {
8478
9163
  app.registerPage("system/dashboard", AdminDashboardPage, { permissions: ["view_admin"] });
8479
9164
  app.registerPage("system/jobs", JobsAdminPage, { permissions: ["view_jobs", "manage_jobs"] });
@@ -8502,6 +9187,8 @@ function registerSystemPages(app, addToMenu = true) {
8502
9187
  app.registerPage("system/push/templates", PushTemplateTablePage, { permissions: ["manage_users"] });
8503
9188
  app.registerPage("system/push/deliveries", PushDeliveryTablePage, { permissions: ["manage_users"] });
8504
9189
  app.registerPage("system/push/devices", PushDeviceTablePage, { permissions: ["manage_users"] });
9190
+ app.registerPage("system/phonehub/numbers", PhoneNumberTablePage, { permissions: ["manage_users"] });
9191
+ app.registerPage("system/phonehub/sms", SMSTablePage, { permissions: ["manage_users"] });
8505
9192
  if (addToMenu && app.sidebar && app.sidebar.getMenuConfig) {
8506
9193
  const adminMenuConfig = app.sidebar.getMenuConfig("system");
8507
9194
  if (adminMenuConfig && adminMenuConfig.items) {
@@ -8676,6 +9363,26 @@ function registerSystemPages(app, addToMenu = true) {
8676
9363
  permissions: ["manage_aws"]
8677
9364
  }
8678
9365
  ]
9366
+ },
9367
+ {
9368
+ text: "Phone Hub",
9369
+ route: null,
9370
+ icon: "bi-telephone",
9371
+ permissions: ["manage_users"],
9372
+ children: [
9373
+ {
9374
+ text: "Numbers",
9375
+ route: "?page=system/phonehub/numbers",
9376
+ icon: "bi-collection",
9377
+ permissions: ["manage_users"]
9378
+ },
9379
+ {
9380
+ text: "SMS",
9381
+ route: "?page=system/phonehub/sms",
9382
+ icon: "bi-chat-dots",
9383
+ permissions: ["manage_users"]
9384
+ }
9385
+ ]
8679
9386
  }
8680
9387
  ];
8681
9388
  adminMenuConfig.items.unshift(...adminMenuItems);