@consilioweb/payload-support 4.0.0 → 6.0.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 (159) hide show
  1. package/README.md +272 -35
  2. package/dist/components/RichTextEditor/index.js +1 -1
  3. package/dist/components/TicketConversation/RewriteDropdown.js +1 -1
  4. package/dist/components/TicketConversation/components/AISummaryPanel.js +3 -0
  5. package/dist/components/TicketConversation/components/ActionPanels.js +13 -8
  6. package/dist/components/TicketConversation/components/ClientHistory.js +1 -0
  7. package/dist/components/TicketConversation/components/CodeBlock.js +1 -0
  8. package/dist/components/TicketConversation/components/CodeBlockInserter.js +1 -1
  9. package/dist/components/TicketConversation/components/QuickActions.js +10 -8
  10. package/dist/components/TicketConversation/components/TicketHeader.js +2 -0
  11. package/dist/components/TicketConversation/components/TimeTrackingPanel.js +15 -9
  12. package/dist/components/TicketConversation/index.d.ts +10 -0
  13. package/dist/components/TicketConversation/index.js +25 -7
  14. package/dist/index.cjs +421 -73
  15. package/dist/index.d.cts +127 -38
  16. package/dist/index.d.ts +127 -38
  17. package/dist/index.js +415 -72
  18. package/dist/styles/BillingView.module.scss +11 -11
  19. package/dist/styles/ChatView.module.scss +7 -7
  20. package/dist/styles/CommandPalette.module.scss +1 -1
  21. package/dist/styles/CrmView.module.scss +9 -9
  22. package/dist/styles/EmailTracking.module.scss +6 -6
  23. package/dist/styles/ImportConversation.module.scss +5 -5
  24. package/dist/styles/Logs.module.scss +5 -5
  25. package/dist/styles/NewTicket.module.scss +2 -2
  26. package/dist/styles/PendingEmails.module.scss +13 -13
  27. package/dist/styles/SupportDashboard.module.scss +24 -8
  28. package/dist/styles/TicketDetail.module.scss +20 -20
  29. package/dist/styles/TicketInbox.module.scss +6 -6
  30. package/dist/styles/TicketingSettings.module.scss +10 -10
  31. package/dist/styles/TimeDashboard.module.scss +6 -6
  32. package/dist/styles/_tokens.scss +3 -1
  33. package/dist/utils/db.d.ts +34 -0
  34. package/dist/utils/readSettings.d.ts +90 -0
  35. package/dist/views/BillingView/client.js +15 -8
  36. package/dist/views/BillingView/index.js +4 -4
  37. package/dist/views/ChatView/client.js +2 -0
  38. package/dist/views/ChatView/index.js +4 -4
  39. package/dist/views/CrmView/client.js +2 -0
  40. package/dist/views/CrmView/index.js +4 -4
  41. package/dist/views/EmailTrackingView/client.js +23 -11
  42. package/dist/views/EmailTrackingView/index.js +4 -4
  43. package/dist/views/ImportConversationView/client.js +1 -0
  44. package/dist/views/ImportConversationView/index.js +4 -4
  45. package/dist/views/LogsView/client.js +1 -1
  46. package/dist/views/LogsView/index.js +4 -2
  47. package/dist/views/NewTicketView/client.js +20 -13
  48. package/dist/views/NewTicketView/index.js +4 -2
  49. package/dist/views/PendingEmailsView/client.js +9 -3
  50. package/dist/views/PendingEmailsView/index.js +4 -4
  51. package/dist/views/SupportDashboardView/client.js +17 -19
  52. package/dist/views/SupportDashboardView/index.js +4 -4
  53. package/dist/views/TicketDetailView/client.js +23 -11
  54. package/dist/views/TicketDetailView/index.js +4 -4
  55. package/dist/views/TicketInboxView/client.js +52 -26
  56. package/dist/views/TicketInboxView/index.js +4 -2
  57. package/dist/views/TicketingSettingsView/TicketingSettings.module.scss +7 -7
  58. package/dist/views/TicketingSettingsView/client.js +44 -26
  59. package/dist/views/TicketingSettingsView/index.js +4 -4
  60. package/dist/views/TimeDashboardView/client.js +10 -7
  61. package/dist/views/TimeDashboardView/index.js +4 -4
  62. package/dist/views/shared/ErrorBoundary.d.ts +36 -1
  63. package/dist/views/shared/ErrorBoundary.js +59 -28
  64. package/dist/views/shared/adminTokens.d.ts +15 -5
  65. package/dist/views/shared/adminTokens.js +23 -7
  66. package/dist/views/shared/icons.d.ts +49 -0
  67. package/dist/views/shared/icons.js +92 -0
  68. package/dist/views/shared/locales/en.json +15 -1
  69. package/dist/views/shared/locales/fr.json +15 -1
  70. package/dist/views/shared/viewAccess.d.ts +29 -0
  71. package/dist/views/shared/viewAccess.js +24 -0
  72. package/package.json +31 -21
  73. package/scripts/uninstall-data.mjs +178 -0
  74. package/scripts/uninstall.mjs +184 -0
  75. package/src/collections/Tickets.ts +7 -0
  76. package/src/collections/TimeEntries.ts +11 -3
  77. package/src/components/RichTextEditor/index.tsx +1 -1
  78. package/src/components/TicketConversation/RewriteDropdown.tsx +1 -1
  79. package/src/components/TicketConversation/components/AISummaryPanel.tsx +3 -0
  80. package/src/components/TicketConversation/components/ActionPanels.tsx +13 -8
  81. package/src/components/TicketConversation/components/ClientHistory.tsx +1 -0
  82. package/src/components/TicketConversation/components/CodeBlock.tsx +1 -0
  83. package/src/components/TicketConversation/components/CodeBlockInserter.tsx +1 -1
  84. package/src/components/TicketConversation/components/QuickActions.tsx +10 -8
  85. package/src/components/TicketConversation/components/TicketHeader.tsx +2 -0
  86. package/src/components/TicketConversation/components/TimeTrackingPanel.tsx +17 -9
  87. package/src/components/TicketConversation/index.tsx +41 -7
  88. package/src/endpoints/auth-2fa.ts +53 -8
  89. package/src/endpoints/capabilities.ts +2 -4
  90. package/src/endpoints/chatbot.ts +2 -2
  91. package/src/endpoints/delete-account.ts +191 -47
  92. package/src/endpoints/export-data.ts +104 -4
  93. package/src/endpoints/import-conversation.ts +2 -2
  94. package/src/endpoints/login.ts +13 -3
  95. package/src/endpoints/purge-logs.ts +6 -15
  96. package/src/endpoints/push.ts +14 -1
  97. package/src/endpoints/statuses.ts +17 -0
  98. package/src/index.ts +2 -0
  99. package/src/plugin.ts +51 -1
  100. package/src/portal/LiveChat.tsx +1 -1
  101. package/src/portal/auth/profile/page.tsx +3 -2
  102. package/src/portal/icons.tsx +91 -0
  103. package/src/portal/login/page.tsx +14 -4
  104. package/src/styles/BillingView.module.scss +11 -11
  105. package/src/styles/ChatView.module.scss +7 -7
  106. package/src/styles/CommandPalette.module.scss +1 -1
  107. package/src/styles/CrmView.module.scss +9 -9
  108. package/src/styles/EmailTracking.module.scss +6 -6
  109. package/src/styles/ImportConversation.module.scss +5 -5
  110. package/src/styles/Logs.module.scss +5 -5
  111. package/src/styles/NewTicket.module.scss +2 -2
  112. package/src/styles/PendingEmails.module.scss +13 -13
  113. package/src/styles/SupportDashboard.module.scss +24 -8
  114. package/src/styles/TicketDetail.module.scss +20 -20
  115. package/src/styles/TicketInbox.module.scss +6 -6
  116. package/src/styles/TicketingSettings.module.scss +10 -10
  117. package/src/styles/TimeDashboard.module.scss +6 -6
  118. package/src/styles/_tokens.scss +3 -1
  119. package/src/types.ts +20 -0
  120. package/src/utils/push.ts +22 -0
  121. package/src/utils/rateLimiter.ts +98 -0
  122. package/src/utils/retention.ts +111 -0
  123. package/src/utils/slugs.ts +15 -0
  124. package/src/utils/twoFactorChallenge.ts +6 -1
  125. package/src/utils/urlSafety.ts +36 -1
  126. package/src/views/BillingView/client.tsx +17 -8
  127. package/src/views/BillingView/index.tsx +4 -4
  128. package/src/views/ChatView/client.tsx +2 -0
  129. package/src/views/ChatView/index.tsx +4 -4
  130. package/src/views/CrmView/client.tsx +2 -0
  131. package/src/views/CrmView/index.tsx +4 -4
  132. package/src/views/EmailTrackingView/client.tsx +18 -7
  133. package/src/views/EmailTrackingView/index.tsx +4 -4
  134. package/src/views/ImportConversationView/client.tsx +1 -0
  135. package/src/views/ImportConversationView/index.tsx +4 -4
  136. package/src/views/LogsView/client.tsx +1 -1
  137. package/src/views/LogsView/index.tsx +4 -2
  138. package/src/views/NewTicketView/client.tsx +22 -13
  139. package/src/views/NewTicketView/index.tsx +4 -2
  140. package/src/views/PendingEmailsView/client.tsx +9 -3
  141. package/src/views/PendingEmailsView/index.tsx +4 -4
  142. package/src/views/SupportDashboardView/client.tsx +11 -5
  143. package/src/views/SupportDashboardView/index.tsx +4 -4
  144. package/src/views/TicketDetailView/client.tsx +25 -11
  145. package/src/views/TicketDetailView/index.tsx +4 -4
  146. package/src/views/TicketInboxView/client.tsx +47 -7
  147. package/src/views/TicketInboxView/index.tsx +4 -2
  148. package/src/views/TicketingSettingsView/TicketingSettings.module.scss +7 -7
  149. package/src/views/TicketingSettingsView/client.tsx +201 -144
  150. package/src/views/TicketingSettingsView/index.tsx +4 -4
  151. package/src/views/TimeDashboardView/client.tsx +10 -7
  152. package/src/views/TimeDashboardView/index.tsx +4 -4
  153. package/src/views/shared/ErrorBoundary.tsx +95 -31
  154. package/src/views/shared/adminTokens.ts +28 -11
  155. package/src/views/shared/icons.tsx +146 -0
  156. package/src/views/shared/locales/en.json +15 -1
  157. package/src/views/shared/locales/fr.json +15 -1
  158. package/src/views/shared/viewAccess.ts +73 -0
  159. package/src/types/lucide-react.d.ts +0 -42
@@ -26,7 +26,7 @@ $radius-md: 12px;
26
26
 
27
27
  .subtitle {
28
28
  font-size: 13px;
29
- color: var(--theme-elevation-500);
29
+ color: var(--theme-elevation-650);
30
30
  margin: 0;
31
31
  }
32
32
 
@@ -75,7 +75,7 @@ $radius-md: 12px;
75
75
  font-weight: 700;
76
76
  text-transform: uppercase;
77
77
  letter-spacing: 0.6px;
78
- color: var(--theme-elevation-500);
78
+ color: var(--theme-elevation-650);
79
79
  margin-bottom: 8px;
80
80
  }
81
81
 
@@ -148,7 +148,7 @@ $radius-md: 12px;
148
148
  font-weight: 700;
149
149
  text-transform: uppercase;
150
150
  letter-spacing: 0.5px;
151
- color: var(--theme-elevation-500);
151
+ color: var(--theme-elevation-650);
152
152
  margin: 0 0 16px;
153
153
  }
154
154
 
@@ -207,7 +207,7 @@ $radius-md: 12px;
207
207
  font-weight: 600;
208
208
  font-variant-numeric: tabular-nums;
209
209
  font-size: 12px;
210
- color: var(--theme-elevation-500);
210
+ color: var(--theme-elevation-650);
211
211
  white-space: nowrap;
212
212
  }
213
213
 
@@ -219,9 +219,25 @@ $radius-md: 12px;
219
219
  font-weight: 500;
220
220
  }
221
221
 
222
+ // The whole row looks clickable, but only this link actually is — it is what
223
+ // carries the ticket into the keyboard tab order.
224
+ .ticketLink {
225
+ display: block;
226
+ overflow: hidden;
227
+ text-overflow: ellipsis;
228
+ white-space: nowrap;
229
+ color: inherit;
230
+ text-decoration: none;
231
+
232
+ &:hover,
233
+ &:focus-visible {
234
+ text-decoration: underline;
235
+ }
236
+ }
237
+
222
238
  .ticketClient {
223
239
  font-size: 12px;
224
- color: var(--theme-elevation-500);
240
+ color: var(--theme-elevation-650);
225
241
  max-width: 120px;
226
242
  overflow: hidden;
227
243
  text-overflow: ellipsis;
@@ -430,7 +446,7 @@ $radius-md: 12px;
430
446
  font-weight: 700;
431
447
  text-transform: uppercase;
432
448
  letter-spacing: 0.5px;
433
- color: var(--theme-elevation-500);
449
+ color: var(--theme-elevation-650);
434
450
  margin: 0 0 16px;
435
451
  }
436
452
 
@@ -582,7 +598,7 @@ $radius-md: 12px;
582
598
  font-size: 12px;
583
599
  font-weight: 600;
584
600
  font-variant-numeric: tabular-nums;
585
- color: var(--theme-elevation-500);
601
+ color: var(--theme-elevation-650);
586
602
  white-space: nowrap;
587
603
  flex-shrink: 0;
588
604
  }
@@ -637,7 +653,7 @@ $radius-md: 12px;
637
653
  .errorState {
638
654
  text-align: center;
639
655
  padding: 60px 24px;
640
- color: var(--theme-elevation-500);
656
+ color: var(--theme-elevation-650);
641
657
  font-size: 14px;
642
658
 
643
659
  strong {
@@ -52,7 +52,7 @@
52
52
  font-family: ui-monospace, 'SF Mono', 'JetBrains Mono', Menlo, Consolas, monospace;
53
53
  font-size: 12px;
54
54
  font-weight: 400;
55
- color: var(--theme-text-tertiary, var(--theme-elevation-500));
55
+ color: var(--theme-text-tertiary, var(--theme-elevation-650));
56
56
  display: inline-flex;
57
57
  align-items: center;
58
58
  gap: 8px;
@@ -64,7 +64,7 @@
64
64
 
65
65
  > .ticketNumberCat,
66
66
  > .ticketNumberSource {
67
- color: var(--theme-text-tertiary, var(--theme-elevation-500));
67
+ color: var(--theme-text-tertiary, var(--theme-elevation-650));
68
68
  display: inline-flex;
69
69
  align-items: center;
70
70
  gap: 4px;
@@ -194,7 +194,7 @@
194
194
  text-transform: uppercase;
195
195
  letter-spacing: 0.08em;
196
196
  line-height: 1.3;
197
- color: var(--theme-text-tertiary, var(--theme-elevation-500));
197
+ color: var(--theme-text-tertiary, var(--theme-elevation-650));
198
198
  margin: 0 0 12px;
199
199
  display: flex;
200
200
  justify-content: space-between;
@@ -353,7 +353,7 @@
353
353
  font-size: 11px;
354
354
  font-weight: 500;
355
355
  line-height: 1.3;
356
- color: var(--theme-text-tertiary, var(--theme-elevation-500));
356
+ color: var(--theme-text-tertiary, var(--theme-elevation-650));
357
357
  white-space: nowrap;
358
358
  text-transform: uppercase;
359
359
  letter-spacing: 0.08em;
@@ -450,7 +450,7 @@
450
450
  font-size: 11px;
451
451
  font-weight: 500;
452
452
  line-height: 1.3;
453
- color: var(--theme-text-tertiary, var(--theme-elevation-500));
453
+ color: var(--theme-text-tertiary, var(--theme-elevation-650));
454
454
  }
455
455
 
456
456
  // Pills "Interne" / "Solution" — pill style, 22px height
@@ -499,7 +499,7 @@
499
499
  margin-top: 4px;
500
500
  font-size: 11px;
501
501
  font-weight: 500;
502
- color: var(--theme-text-tertiary, var(--theme-elevation-500));
502
+ color: var(--theme-text-tertiary, var(--theme-elevation-650));
503
503
  opacity: 0;
504
504
  transition: opacity 80ms ease;
505
505
  }
@@ -514,7 +514,7 @@
514
514
  font-size: 11px;
515
515
  font-weight: 500;
516
516
  line-height: 1.3;
517
- color: var(--theme-text-tertiary, var(--theme-elevation-500));
517
+ color: var(--theme-text-tertiary, var(--theme-elevation-650));
518
518
  display: inline-flex;
519
519
  align-items: center;
520
520
  height: auto;
@@ -749,7 +749,7 @@
749
749
  display: inline-flex;
750
750
  align-items: center;
751
751
  gap: 4px;
752
- color: var(--theme-text-tertiary, var(--theme-elevation-500));
752
+ color: var(--theme-text-tertiary, var(--theme-elevation-650));
753
753
  font-size: 11px;
754
754
  font-weight: 500;
755
755
  margin-left: auto;
@@ -910,7 +910,7 @@
910
910
  background: none;
911
911
  border: none;
912
912
  cursor: pointer;
913
- color: var(--theme-elevation-500);
913
+ color: var(--theme-elevation-650);
914
914
  font-size: 13px;
915
915
  font-weight: 600;
916
916
  padding: 0;
@@ -941,7 +941,7 @@
941
941
 
942
942
  .activityContent { flex: 1; min-width: 0; }
943
943
  .activityText { font-weight: 500; color: var(--theme-text); font-size: 12px; }
944
- .activityTime { font-size: 11px; color: var(--theme-elevation-500); margin-top: 1px; }
944
+ .activityTime { font-size: 11px; color: var(--theme-elevation-650); margin-top: 1px; }
945
945
 
946
946
  // ─── DROPDOWN ───
947
947
  .dropdown { position: relative; display: inline-block; }
@@ -1055,7 +1055,7 @@
1055
1055
 
1056
1056
  .splitPreview {
1057
1057
  font-size: 12px;
1058
- color: var(--theme-elevation-500);
1058
+ color: var(--theme-elevation-650);
1059
1059
  margin: 0 0 16px;
1060
1060
  line-height: 1.5;
1061
1061
  max-height: 80px;
@@ -1092,7 +1092,7 @@
1092
1092
  background: none;
1093
1093
  font-size: 13px;
1094
1094
  font-weight: 600;
1095
- color: var(--theme-elevation-500);
1095
+ color: var(--theme-elevation-650);
1096
1096
  cursor: pointer;
1097
1097
  &:hover { background: var(--theme-elevation-100); }
1098
1098
  }
@@ -1145,7 +1145,7 @@
1145
1145
  cursor: pointer;
1146
1146
  font-size: 11px;
1147
1147
  line-height: 1;
1148
- color: var(--theme-text-tertiary, var(--theme-elevation-500));
1148
+ color: var(--theme-text-tertiary, var(--theme-elevation-650));
1149
1149
  padding: 0;
1150
1150
  display: inline-flex;
1151
1151
  align-items: center;
@@ -1166,7 +1166,7 @@
1166
1166
  font-size: 11px;
1167
1167
  font-weight: 500;
1168
1168
  line-height: 1;
1169
- color: var(--theme-text-tertiary, var(--theme-elevation-500));
1169
+ color: var(--theme-text-tertiary, var(--theme-elevation-650));
1170
1170
  cursor: pointer;
1171
1171
  transition: border-color 80ms ease, color 80ms ease;
1172
1172
  &:hover {
@@ -1607,7 +1607,7 @@
1607
1607
  align-items: center;
1608
1608
  gap: 6px;
1609
1609
  font-size: 10px;
1610
- color: var(--theme-elevation-500);
1610
+ color: var(--theme-elevation-650);
1611
1611
  }
1612
1612
 
1613
1613
  .previousTicketsStatus {
@@ -1755,7 +1755,7 @@
1755
1755
  text-transform: uppercase;
1756
1756
  letter-spacing: 0.08em;
1757
1757
  line-height: 1.3;
1758
- color: var(--theme-text-tertiary, var(--theme-elevation-500));
1758
+ color: var(--theme-text-tertiary, var(--theme-elevation-650));
1759
1759
  margin: 12px 0 8px;
1760
1760
  }
1761
1761
  .aiCardNextActions {
@@ -1805,13 +1805,13 @@
1805
1805
  }
1806
1806
  .aiCardNextLabelDone {
1807
1807
  text-decoration: line-through;
1808
- color: var(--theme-text-tertiary, var(--theme-elevation-500));
1808
+ color: var(--theme-text-tertiary, var(--theme-elevation-650));
1809
1809
  }
1810
1810
  .aiCardNextWhen {
1811
1811
  font-size: 11px;
1812
1812
  font-weight: 500;
1813
1813
  line-height: 1.3;
1814
- color: var(--theme-text-tertiary, var(--theme-elevation-500));
1814
+ color: var(--theme-text-tertiary, var(--theme-elevation-650));
1815
1815
  flex-shrink: 0;
1816
1816
  }
1817
1817
 
@@ -1856,12 +1856,12 @@
1856
1856
  margin: 0 2px;
1857
1857
  }
1858
1858
  .ticketNumberCat {
1859
- color: var(--theme-text-tertiary, var(--theme-elevation-500));
1859
+ color: var(--theme-text-tertiary, var(--theme-elevation-650));
1860
1860
  font-family: ui-sans-serif, system-ui, -apple-system, sans-serif;
1861
1861
  font-weight: 400;
1862
1862
  }
1863
1863
  .ticketNumberSource {
1864
- color: var(--theme-text-tertiary, var(--theme-elevation-500));
1864
+ color: var(--theme-text-tertiary, var(--theme-elevation-650));
1865
1865
  font-family: ui-sans-serif, system-ui, -apple-system, sans-serif;
1866
1866
  display: inline-flex;
1867
1867
  align-items: center;
@@ -26,7 +26,7 @@
26
26
 
27
27
  .subtitle {
28
28
  font-size: 13px;
29
- color: var(--theme-elevation-500);
29
+ color: var(--theme-elevation-650);
30
30
  margin: 2px 0 0;
31
31
  }
32
32
 
@@ -125,7 +125,7 @@
125
125
  padding: 0 12px;
126
126
  font-size: 13px;
127
127
  font-weight: 500;
128
- color: var(--theme-elevation-500);
128
+ color: var(--theme-elevation-650);
129
129
  background: none;
130
130
  border: none;
131
131
  border-radius: 6px;
@@ -155,7 +155,7 @@
155
155
  padding: 0 6px;
156
156
  font-size: 11px;
157
157
  font-weight: 600;
158
- color: var(--theme-elevation-500);
158
+ color: var(--theme-elevation-650);
159
159
  background: var(--theme-elevation-200);
160
160
  border-radius: 999px;
161
161
  }
@@ -179,7 +179,7 @@
179
179
  .sortSelect {
180
180
  font-size: 12px;
181
181
  font-weight: 600;
182
- color: var(--theme-elevation-500);
182
+ color: var(--theme-elevation-650);
183
183
  background: none;
184
184
  border: none;
185
185
  cursor: pointer;
@@ -268,7 +268,7 @@
268
268
 
269
269
  .client {
270
270
  font-size: 12px;
271
- color: var(--theme-elevation-500);
271
+ color: var(--theme-elevation-650);
272
272
  overflow: hidden;
273
273
  text-overflow: ellipsis;
274
274
  white-space: nowrap;
@@ -333,7 +333,7 @@
333
333
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
334
334
  font-size: 11px;
335
335
  font-weight: 500;
336
- color: var(--theme-elevation-500);
336
+ color: var(--theme-elevation-650);
337
337
  display: flex;
338
338
  gap: 12px;
339
339
  z-index: 10;
@@ -15,7 +15,7 @@ $green: #16a34a;
15
15
 
16
16
  .intro {
17
17
  font-size: 13px;
18
- color: var(--theme-elevation-500);
18
+ color: var(--theme-elevation-650);
19
19
  margin: 0 0 24px;
20
20
  line-height: 1.6;
21
21
  }
@@ -59,7 +59,7 @@ $green: #16a34a;
59
59
 
60
60
  .sectionChevron {
61
61
  font-size: 16px;
62
- color: var(--theme-elevation-500);
62
+ color: var(--theme-elevation-650);
63
63
  transition: transform 150ms;
64
64
 
65
65
  &.open {
@@ -73,7 +73,7 @@ $green: #16a34a;
73
73
 
74
74
  .sectionDescription {
75
75
  font-size: 12px;
76
- color: var(--theme-elevation-500);
76
+ color: var(--theme-elevation-650);
77
77
  margin: 0 0 16px;
78
78
  line-height: 1.5;
79
79
  }
@@ -97,7 +97,7 @@ $green: #16a34a;
97
97
 
98
98
  .fieldDescription {
99
99
  font-size: 11px;
100
- color: var(--theme-elevation-500);
100
+ color: var(--theme-elevation-650);
101
101
  font-weight: 400;
102
102
  margin-top: 2px;
103
103
  }
@@ -131,7 +131,7 @@ $green: #16a34a;
131
131
  border-radius: 6px;
132
132
  border: 1px solid var(--theme-elevation-300);
133
133
  font-size: 13px;
134
- color: var(--theme-elevation-500);
134
+ color: var(--theme-elevation-650);
135
135
  background-color: var(--theme-elevation-100);
136
136
  width: 100%;
137
137
  max-width: 400px;
@@ -179,7 +179,7 @@ $green: #16a34a;
179
179
 
180
180
  .inlineDesc {
181
181
  font-size: 12px;
182
- color: var(--theme-elevation-500);
182
+ color: var(--theme-elevation-650);
183
183
  }
184
184
 
185
185
  // ─── BADGE ───
@@ -251,7 +251,7 @@ $green: #16a34a;
251
251
 
252
252
  .featureDesc {
253
253
  font-size: 12px;
254
- color: var(--theme-elevation-500);
254
+ color: var(--theme-elevation-650);
255
255
  line-height: 1.4;
256
256
  }
257
257
 
@@ -271,7 +271,7 @@ $green: #16a34a;
271
271
 
272
272
  .aiToggleDesc {
273
273
  font-size: 12px;
274
- color: var(--theme-elevation-500);
274
+ color: var(--theme-elevation-650);
275
275
  margin-left: 8px;
276
276
  }
277
277
 
@@ -290,7 +290,7 @@ $green: #16a34a;
290
290
  background-color: transparent;
291
291
  cursor: pointer;
292
292
  font-size: 12px;
293
- color: var(--theme-elevation-500);
293
+ color: var(--theme-elevation-650);
294
294
  white-space: nowrap;
295
295
  }
296
296
 
@@ -303,7 +303,7 @@ $green: #16a34a;
303
303
 
304
304
  .slaHint {
305
305
  font-size: 12px;
306
- color: var(--theme-elevation-500);
306
+ color: var(--theme-elevation-650);
307
307
  }
308
308
 
309
309
  // ─── AI SUB FEATURES LABEL ───
@@ -28,7 +28,7 @@
28
28
  }
29
29
 
30
30
  .subtitle {
31
- color: var(--theme-elevation-500);
31
+ color: var(--theme-elevation-650);
32
32
  margin: 4px 0 0;
33
33
  font-size: 14px;
34
34
  }
@@ -80,7 +80,7 @@
80
80
  .label {
81
81
  font-size: 11px;
82
82
  font-weight: 700;
83
- color: var(--theme-elevation-500);
83
+ color: var(--theme-elevation-650);
84
84
  margin-bottom: 3px;
85
85
  }
86
86
 
@@ -146,7 +146,7 @@
146
146
  font-size: 10px;
147
147
  font-weight: 700;
148
148
  text-transform: uppercase;
149
- color: var(--theme-elevation-500);
149
+ color: var(--theme-elevation-650);
150
150
  }
151
151
 
152
152
  .kpiValue {
@@ -201,7 +201,7 @@
201
201
  border: 1px solid var(--theme-elevation-300);
202
202
  border-radius: 8px;
203
203
  background: var(--theme-elevation-100);
204
- color: var(--theme-elevation-500);
204
+ color: var(--theme-elevation-650);
205
205
  font-size: 14px;
206
206
  }
207
207
 
@@ -257,13 +257,13 @@
257
257
 
258
258
  .tdSubject {
259
259
  padding: 6px 8px;
260
- color: var(--theme-elevation-500);
260
+ color: var(--theme-elevation-650);
261
261
  font-size: 12px;
262
262
  }
263
263
 
264
264
  .tdDescription {
265
265
  padding: 6px 8px;
266
- color: var(--theme-elevation-500);
266
+ color: var(--theme-elevation-650);
267
267
  font-size: 12px;
268
268
  }
269
269
 
@@ -1,6 +1,8 @@
1
1
  // Design tokens — based on Payload CMS CSS variables
2
2
  $text: var(--theme-text);
3
- $text-secondary: var(--theme-elevation-500);
3
+ // elevation-650, not 500: Payload's dark theme skips the 500 step, leaving it
4
+ // at rgb(128,128,128) — 3.62:1 light / 4.03:1 dark, below WCAG AA in both.
5
+ $text-secondary: var(--theme-elevation-650);
4
6
  $text-muted: var(--theme-elevation-400);
5
7
  $bg: var(--theme-elevation-50);
6
8
  $bg-card: var(--theme-elevation-100);
package/src/types.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { RateLimitStore } from './utils/rateLimiter'
2
+ import type { RetentionConfig } from './utils/retention'
2
3
  import type { InboundEmailInput } from './utils/webhookSecurity'
3
4
  import type { Payload, PayloadRequest } from 'payload'
4
5
 
@@ -151,6 +152,25 @@ export interface SupportPluginConfig {
151
152
  /** Shared rate-limit storage. Defaults to process-local memory. */
152
153
  rateLimitStore?: RateLimitStore | 'payload'
153
154
 
155
+ /**
156
+ * Automatic retention of the two journals the plugin writes.
157
+ *
158
+ * The plugin registers a daily Payload Jobs task that deletes rows older
159
+ * than the configured number of days. Set a value to 0 to keep that journal
160
+ * forever (the manual `DELETE /api/support/purge-logs` endpoint stays
161
+ * available either way).
162
+ *
163
+ * Defaults: auth-logs 180 days, email-logs 365 days, cron `0 30 3 * * *`.
164
+ *
165
+ * `false` registers no task at all. That is the ONLY value that changes the
166
+ * schema footprint: registering a task turns Payload's job queue on, which
167
+ * adds the `payload-jobs` collection and the `payload-jobs-stats` global to
168
+ * an app that had no jobs — two tables you would have to migrate. Pass
169
+ * `false` if you would rather purge from your own cron, calling
170
+ * `DELETE /api/support/purge-logs`.
171
+ */
172
+ retention?: RetentionConfig | false
173
+
154
174
  /** Sequential ticket number formatting. */
155
175
  ticketNumber?: { prefix?: string; padding?: number }
156
176
 
package/src/utils/push.ts CHANGED
@@ -2,6 +2,7 @@ import type { Payload } from 'payload'
2
2
  import type { CollectionSlugs } from './slugs'
3
3
  import { dbFind, dbDelete } from './db'
4
4
  import webpush from 'web-push'
5
+ import { assertPublicHost, validatePushEndpoint } from './urlSafety'
5
6
 
6
7
  let configured = false
7
8
  function ensureVapid(): boolean {
@@ -43,6 +44,27 @@ export async function sendPushToUser(
43
44
  for (const s of subs.docs) {
44
45
  const row = s as { id: number | string; endpoint?: string; p256dh?: string; auth?: string }
45
46
  if (!row.endpoint || !row.p256dh || !row.auth) continue
47
+
48
+ // SEND-time SSRF guard, the second of the two layers `utils/urlSafety`
49
+ // describes. It is not redundant with the write-time check: it also covers
50
+ // rows persisted BEFORE that check existed, and a name whose DNS record is
51
+ // flipped to a private address after the row was accepted. `web-push`
52
+ // builds its own `https.request`, so `safeFetch` cannot wrap it — this is
53
+ // the layer that stands in for it.
54
+ //
55
+ // A blocked row is SKIPPED, never deleted: `assertPublicHost` fails closed
56
+ // on a resolver timeout, and a hiccup must not purge a legitimate agent's
57
+ // subscription. Only a real 404/410 from the push service prunes below.
58
+ const check = validatePushEndpoint(row.endpoint)
59
+ if (!check.ok || !check.url) {
60
+ console.warn('[support] Push endpoint refused (unsafe URL):', check.reason)
61
+ continue
62
+ }
63
+ if (!(await assertPublicHost(check.url.hostname))) {
64
+ console.warn('[support] Push endpoint refused (host resolves to a private address)')
65
+ continue
66
+ }
67
+
46
68
  try {
47
69
  await webpush.sendNotification(
48
70
  { endpoint: row.endpoint, keys: { p256dh: row.p256dh, auth: row.auth } },
@@ -8,13 +8,59 @@ export interface RateLimitStore {
8
8
  reset(key: string, context?: unknown): Promise<void>
9
9
  }
10
10
 
11
+ /**
12
+ * Hard ceiling on the number of distinct keys held at once, all endpoints
13
+ * combined. Sized for a busy install (thousands of client IPs inside one
14
+ * 15-minute window) while capping the map at a few hundred kilobytes.
15
+ */
16
+ export const MAX_MEMORY_RATE_LIMIT_KEYS = 10_000
17
+
11
18
  /**
12
19
  * Process-local fallback store. Applications running more than one process
13
20
  * should provide a persistent RateLimitStore through the plugin options.
21
+ *
22
+ * BOUNDED, because the keys come from anonymous HTTP traffic: `/support/login`
23
+ * and `/support/chatbot` key their limiter on the client IP, and the IP is read
24
+ * from `x-forwarded-for`. A caller rotating that header wrote one PERMANENT
25
+ * entry per value into a map that had no ceiling, no expiry sweep and no
26
+ * eviction — only `reset()` ever removed anything, and the login path never
27
+ * calls it. Worse, every fresh key opened a fresh window, so the limiter did
28
+ * not even slow down the flood that was exhausting it.
29
+ *
30
+ * Two independent bounds, the same pair `endpoints/typing.ts` already applies
31
+ * to its own module-level map: `clientIpRateKey` caps the SIZE of a key, the
32
+ * ceiling below caps their NUMBER.
14
33
  */
15
34
  export class MemoryRateLimitStore implements RateLimitStore {
16
35
  private readonly entries = new Map<string, RateLimitEntry>()
17
36
 
37
+ constructor(private readonly maxKeys: number = MAX_MEMORY_RATE_LIMIT_KEYS) {}
38
+
39
+ /** Distinct keys currently held. Exposed so the ceiling can be asserted. */
40
+ get size(): number {
41
+ return this.entries.size
42
+ }
43
+
44
+ /** Reclaims every window that has already closed. */
45
+ private sweepExpired(now: number): void {
46
+ for (const [key, entry] of this.entries) {
47
+ if (now > entry.resetAt) this.entries.delete(key)
48
+ }
49
+ }
50
+
51
+ /** Soonest-closing window first, so the ceiling drops the least useful entry. */
52
+ private evictOldest(): void {
53
+ let oldestKey: string | null = null
54
+ let oldestResetAt = Infinity
55
+ for (const [key, entry] of this.entries) {
56
+ if (entry.resetAt < oldestResetAt) {
57
+ oldestResetAt = entry.resetAt
58
+ oldestKey = key
59
+ }
60
+ }
61
+ if (oldestKey !== null) this.entries.delete(oldestKey)
62
+ }
63
+
18
64
  async increment(key: string, windowMs: number): Promise<RateLimitEntry> {
19
65
  const now = Date.now()
20
66
  const current = this.entries.get(key)
@@ -22,6 +68,12 @@ export class MemoryRateLimitStore implements RateLimitStore {
22
68
  ? { count: 1, resetAt: now + windowMs }
23
69
  : { ...current, count: current.count + 1 }
24
70
 
71
+ // Only a key that is not already held can grow the map.
72
+ if (!current && this.entries.size >= this.maxKeys) {
73
+ this.sweepExpired(now)
74
+ if (this.entries.size >= this.maxKeys) this.evictOldest()
75
+ }
76
+
25
77
  this.entries.set(key, next)
26
78
  return next
27
79
  }
@@ -134,6 +186,52 @@ export function principalRateKey(
134
186
  return `${collection}:${String(user.id)}`
135
187
  }
136
188
 
189
+ /**
190
+ * Longest textual IPv6 address, `xxxx:` * 7 + an embedded IPv4 literal.
191
+ * Nothing legitimate is longer; the cap is what makes the key bounded.
192
+ */
193
+ const MAX_IP_KEY_LENGTH = 45
194
+ /** Dotted-quad, or the hex/`::`/zone alphabet of an IPv6 literal. Shape only. */
195
+ const IPV4_PATTERN = /^(?:\d{1,3}\.){3}\d{1,3}$/
196
+ const IPV6_PATTERN = /^[0-9a-fA-F:.%]+$/
197
+
198
+ /**
199
+ * Identity part of a rate-limit key for an ANONYMOUS caller.
200
+ *
201
+ * `x-forwarded-for` is attacker-controlled — the note in `endpoints/login.ts`
202
+ * has always said so, and Payload's account lock is what actually stops a
203
+ * brute-force. But the header was also used RAW as the limiter key, and that is
204
+ * a second, distinct problem: a key is a stored object. A caller rotating the
205
+ * header minted one unbounded, never-reclaimed entry per value; a value can be
206
+ * most of Node's 16 KB header budget. This validates the SHAPE and caps the
207
+ * LENGTH, so a forged header can still pick a bucket but can no longer invent
208
+ * an unbounded number of them, nor make any single one large.
209
+ *
210
+ * Anything that is not an IP literal falls back to the shared `unknown` bucket.
211
+ * That is the fail-closed direction: an install with no proxy already puts
212
+ * every caller there, and one behind a proxy never lands there legitimately.
213
+ */
214
+ export function clientIpRateKey(req: { headers: { get(name: string): string | null } }): string {
215
+ const candidate = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
216
+ || req.headers.get('x-real-ip')?.trim()
217
+ || ''
218
+ return normalizeIpKey(candidate)
219
+ }
220
+
221
+ /** Exposed separately so a caller holding an already-extracted address can reuse it. */
222
+ export function normalizeIpKey(candidate: string): string {
223
+ if (!candidate || candidate.length > MAX_IP_KEY_LENGTH) return 'unknown'
224
+ const host = candidate.startsWith('[') && candidate.endsWith(']')
225
+ ? candidate.slice(1, -1)
226
+ : candidate
227
+ if (!host) return 'unknown'
228
+ if (IPV4_PATTERN.test(host)) {
229
+ return host.split('.').every((octet) => Number(octet) <= 255) ? host : 'unknown'
230
+ }
231
+ if (host.includes(':') && IPV6_PATTERN.test(host)) return host.toLowerCase()
232
+ return 'unknown'
233
+ }
234
+
137
235
  export class RateLimiter {
138
236
  private readonly store: RateLimitStore
139
237
  private readonly prefix: string