@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
@@ -3,16 +3,74 @@ import { getFieldsToSign, jwtSign } from 'payload'
3
3
  import type { CollectionSlugs } from '../utils/slugs'
4
4
  import crypto from 'crypto'
5
5
  import { dbFind, dbUpdate, dbCreate, dbFindByID } from '../utils/db'
6
+ import { issueTwoFactorChallenge } from '../utils/twoFactorChallenge'
6
7
 
7
8
  /**
8
9
  * POST /api/support/oauth/google
9
10
  * Google OAuth — handles both login redirect and callback.
10
- * Body: { action: 'login' } or { code: string, state: string, cookieState: string }
11
+ * Body: { action: 'login' } or { code: string, state: string }
12
+ *
13
+ * The CSRF state is NOT taken from the body: `action: 'login'` issues it as an
14
+ * HttpOnly cookie and the callback reads it back from the `Cookie` header.
11
15
  */
12
16
  export interface OAuthGoogleOptions {
13
17
  allowedEmailDomains?: string[]
14
18
  }
15
19
 
20
+ /** Name of the HttpOnly cookie carrying the CSRF state between the two steps. */
21
+ export const OAUTH_STATE_COOKIE = 'support-oauth-state'
22
+
23
+ /** Same window as the Google authorization code: 10 minutes is plenty. */
24
+ const OAUTH_STATE_MAX_AGE = 600
25
+
26
+ /**
27
+ * A successful `/support/2fa` verify stamps `twoFactorVerifiedAt`; the marker is
28
+ * valid for this window then consumed. Mirrors `TWO_FA_WINDOW_MS` in
29
+ * `collections/SupportClients.ts` — the two MUST stay in sync.
30
+ */
31
+ const TWO_FA_WINDOW_MS = 5 * 60 * 1000
32
+
33
+ /**
34
+ * Read one cookie from the request's own `Cookie` header.
35
+ *
36
+ * The callback used to compare `state` against a `cookieState` ALSO taken from
37
+ * the JSON body: two values from the same attacker-controlled place, so the CSRF
38
+ * check was a no-op for any non-browser caller (send the same string twice). The
39
+ * state is now issued as an HttpOnly cookie at the `login` step and read back
40
+ * here, server-side — the only place a browser cannot forge it from script.
41
+ */
42
+ export function readCookie(header: string | null | undefined, name: string): string | null {
43
+ if (!header) return null
44
+ for (const part of header.split(';')) {
45
+ const eq = part.indexOf('=')
46
+ if (eq === -1) continue
47
+ if (part.slice(0, eq).trim() !== name) continue
48
+ try {
49
+ return decodeURIComponent(part.slice(eq + 1).trim())
50
+ } catch {
51
+ return part.slice(eq + 1).trim()
52
+ }
53
+ }
54
+ return null
55
+ }
56
+
57
+ /**
58
+ * Expire the state cookie: it is single-use, and leaving it live for the rest of
59
+ * its 10 minutes keeps a consumed CSRF token replayable for no benefit.
60
+ */
61
+ export function clearedStateCookie(): string {
62
+ const secure = process.env.NODE_ENV === 'production'
63
+ return `${OAUTH_STATE_COOKIE}=; HttpOnly; ${secure ? 'Secure; ' : ''}SameSite=Lax; Path=/; Max-Age=0`
64
+ }
65
+
66
+ /** Constant-time comparison — the state is a secret for the length of the flow. */
67
+ function statesMatch(a: string | null | undefined, b: string | null | undefined): boolean {
68
+ if (!a || !b) return false
69
+ const left = crypto.createHash('sha256').update(a).digest()
70
+ const right = crypto.createHash('sha256').update(b).digest()
71
+ return crypto.timingSafeEqual(left, right)
72
+ }
73
+
16
74
  export function createOAuthGoogleEndpoint(slugs: CollectionSlugs, options?: OAuthGoogleOptions): Endpoint {
17
75
  return {
18
76
  path: '/support/oauth/google',
@@ -31,7 +89,7 @@ export function createOAuthGoogleEndpoint(slugs: CollectionSlugs, options?: OAut
31
89
 
32
90
  try {
33
91
  const body = await req.json!()
34
- const { action, code, state: queryState, cookieState } = body
92
+ const { action, code, state: queryState } = body
35
93
 
36
94
  // Step 1: Generate OAuth URL
37
95
  if (action === 'login') {
@@ -46,16 +104,30 @@ export function createOAuthGoogleEndpoint(slugs: CollectionSlugs, options?: OAut
46
104
  prompt: 'select_account',
47
105
  })
48
106
 
49
- return Response.json({
50
- url: `https://accounts.google.com/o/oauth2/v2/auth?${params}`,
51
- state: oauthState,
52
- })
107
+ const secure = process.env.NODE_ENV === 'production'
108
+ const loginHeaders = new Headers({ 'Content-Type': 'application/json' })
109
+ loginHeaders.append(
110
+ 'Set-Cookie',
111
+ `${OAUTH_STATE_COOKIE}=${oauthState}; HttpOnly; ${secure ? 'Secure; ' : ''}SameSite=Lax; Path=/; Max-Age=${OAUTH_STATE_MAX_AGE}`,
112
+ )
113
+
114
+ return new Response(
115
+ JSON.stringify({
116
+ url: `https://accounts.google.com/o/oauth2/v2/auth?${params}`,
117
+ // Kept for callers that echo it back in the redirect URL; the
118
+ // server no longer trusts anything the caller returns.
119
+ state: oauthState,
120
+ }),
121
+ { status: 200, headers: loginHeaders },
122
+ )
53
123
  }
54
124
 
55
125
  // Step 2: Handle callback (code exchange)
56
126
  if (code) {
57
- // Validate state
58
- if (!cookieState || !queryState || cookieState !== queryState) {
127
+ // Validate state against the HttpOnly cookie set at the `login` step —
128
+ // NOT against a second copy taken from the request body.
129
+ const issuedState = readCookie(req.headers.get('cookie'), OAUTH_STATE_COOKIE)
130
+ if (!statesMatch(issuedState, queryState)) {
59
131
  return Response.json({ error: 'state_mismatch' }, { status: 400 })
60
132
  }
61
133
 
@@ -158,6 +230,54 @@ export function createOAuthGoogleEndpoint(slugs: CollectionSlugs, options?: OAut
158
230
  }) as { id: number | string; email: string }
159
231
  }
160
232
 
233
+ // Replay the 2FA rule BEFORE minting anything.
234
+ //
235
+ // This path never calls `payload.login`, so the `beforeLogin` hook
236
+ // `createEnforce2FA` (collections/SupportClients.ts) never runs: a
237
+ // client who turned 2FA on from the portal profile page could skip it
238
+ // entirely by clicking "Sign in with Google". Same contract as
239
+ // `endpoints/login.ts`: no token, `{ requires2FA: true }`, and the
240
+ // verified marker is single-use.
241
+ const twoFactorDoc = (await dbFindByID(payload, slugs.supportClients, {
242
+ id: clientDoc.id,
243
+ depth: 0,
244
+ overrideAccess: true,
245
+ showHiddenFields: true,
246
+ })) as { twoFactorEnabled?: boolean; twoFactorVerifiedAt?: string | null }
247
+
248
+ if (twoFactorDoc?.twoFactorEnabled) {
249
+ const raw = twoFactorDoc.twoFactorVerifiedAt
250
+ const verifiedAt = raw ? new Date(raw).getTime() : 0
251
+ if (!(verifiedAt > Date.now() - TWO_FA_WINDOW_MS)) {
252
+ // The authorization code is spent: the client has to restart the
253
+ // whole flow after verifying, so the state goes with it.
254
+ //
255
+ // Google just proved this identity, so the client is entitled to
256
+ // request a code: hand out the same short-lived challenge
257
+ // `endpoints/login.ts` mints, or `POST /support/2fa` would refuse
258
+ // to send one.
259
+ let challenge: string | undefined
260
+ try {
261
+ challenge = issueTwoFactorChallenge(clientDoc.email)
262
+ } catch {
263
+ // PAYLOAD_SECRET missing — 2FA is inoperable anyway; fail closed.
264
+ }
265
+ return new Response(JSON.stringify({ requires2FA: true, ...(challenge ? { challenge } : {}) }), {
266
+ status: 200,
267
+ headers: new Headers({
268
+ 'Content-Type': 'application/json',
269
+ 'Set-Cookie': clearedStateCookie(),
270
+ }),
271
+ })
272
+ }
273
+ // Consume the marker so the verified window cannot be replayed.
274
+ await dbUpdate(payload, slugs.supportClients, {
275
+ id: clientDoc.id,
276
+ data: { twoFactorVerifiedAt: null },
277
+ overrideAccess: true,
278
+ })
279
+ }
280
+
161
281
  // Mint a Payload session WITHOUT touching the user's password.
162
282
  // Uses Payload's own jwtSign/getFieldsToSign so the token format matches
163
283
  // exactly, and replicates addSessionToUser for the sessions array.
@@ -209,6 +329,8 @@ export function createOAuthGoogleEndpoint(slugs: CollectionSlugs, options?: OAut
209
329
  'Set-Cookie',
210
330
  `payload-token=${token}; HttpOnly; ${cookieSecure ? 'Secure; ' : ''}SameSite=Lax; Path=/; Max-Age=${tokenExpiration}`,
211
331
  )
332
+ // The state has done its job — do not leave it replayable.
333
+ headers.append('Set-Cookie', clearedStateCookie())
212
334
 
213
335
  return new Response(JSON.stringify({ user: clientDoc, exp }), {
214
336
  status: 200,
@@ -1,7 +1,7 @@
1
1
  import type { Endpoint } from 'payload'
2
2
  import type { CollectionSlugs } from '../utils/slugs'
3
3
  import { escapeHtml } from '../utils/emailTemplate'
4
- import { fireWebhooks } from '../utils/fireWebhooks'
4
+ import { dispatchWebhook } from '../utils/webhookDispatcher'
5
5
  import { readSupportSettings } from '../utils/readSettings'
6
6
  import { dbFind, dbFindByID, dbUpdate } from '../utils/db'
7
7
  import { verifySecret } from '../utils/webhookSecurity'
@@ -112,13 +112,18 @@ export function createProcessScheduledEndpoint(slugs: CollectionSlugs): Endpoint
112
112
  }
113
113
 
114
114
  // Fire webhook for the now-sent reply
115
- fireWebhooks(payload, slugs, 'ticket_replied', {
116
- ticketId,
117
- messageId: msg.id,
118
- authorType: msg.authorType,
119
- scheduled: true,
120
- body: msg.body?.length > 500 ? msg.body.slice(0, 500) + '...' : msg.body,
121
- })
115
+ dispatchWebhook(
116
+ {
117
+ ticketId,
118
+ messageId: msg.id,
119
+ authorType: msg.authorType,
120
+ scheduled: true,
121
+ body: msg.body?.length > 500 ? msg.body.slice(0, 500) + '...' : msg.body,
122
+ },
123
+ 'ticket_replied',
124
+ payload,
125
+ slugs,
126
+ )
122
127
 
123
128
  results.processed++
124
129
  } catch (err) {
@@ -1,7 +1,7 @@
1
1
  import type { Endpoint } from 'payload'
2
2
  import type { CollectionSlugs } from '../utils/slugs'
3
3
  import { requireAdmin, handleAuthError } from '../utils/auth'
4
- import { RateLimiter, type RateLimitStore } from '../utils/rateLimiter'
4
+ import { principalRateKey, RateLimiter, type RateLimitStore } from '../utils/rateLimiter'
5
5
  import { escapeHtml } from '../utils/emailTemplate'
6
6
  import { readSupportSettings } from '../utils/readSettings'
7
7
  import { dbFindByID } from '../utils/db'
@@ -12,7 +12,7 @@ import { dbFindByID } from '../utils/db'
12
12
  * Resend the email notification for a specific ticket message. Admin-only.
13
13
  */
14
14
  export function createResendNotificationEndpoint(slugs: CollectionSlugs, store?: RateLimitStore): Endpoint {
15
- const resendLimiter = new RateLimiter(60 * 60 * 1000, 10, store)
15
+ const resendLimiter = new RateLimiter(60 * 60 * 1000, 10, store, 'resend-notification')
16
16
  return {
17
17
  path: '/support/resend-notification',
18
18
  method: 'post',
@@ -22,7 +22,7 @@ export function createResendNotificationEndpoint(slugs: CollectionSlugs, store?:
22
22
 
23
23
  requireAdmin(req, slugs)
24
24
 
25
- if (await resendLimiter.check(String(req.user.id), req)) {
25
+ if (await resendLimiter.check(principalRateKey(req.user), req)) {
26
26
  return Response.json(
27
27
  { error: 'Trop de renvois. Réessayez dans une heure.' },
28
28
  { status: 429 },
@@ -1,9 +1,24 @@
1
1
  import type { Endpoint } from 'payload'
2
2
  import type { CollectionSlugs } from '../utils/slugs'
3
3
  import { requireAdmin, handleAuthError } from '../utils/auth'
4
- import { dbFind } from '../utils/db'
4
+ import {
5
+ SUPPORT_SETTINGS_PREF_KEY as PREF_KEY,
6
+ invalidateSupportSettingsCache,
7
+ mergeSupportSettings,
8
+ readSupportSettingsState,
9
+ } from '../utils/readSettings'
10
+ import { stripProjectedFeatures } from '../utils/features'
5
11
 
6
- const PREF_KEY = 'support-round-robin'
12
+ /**
13
+ * Round-robin used to live in its own `support-round-robin` preference row,
14
+ * while the admin UI toggled a `roundRobin` flag in `localStorage` — the two
15
+ * never met, so the toggle had no effect on assignment.
16
+ *
17
+ * Both endpoints below now read and write `settings.features.roundRobin`, the
18
+ * same value `/api/support/settings` exposes and `Tickets`' auto-assign hook
19
+ * reads. The legacy row is still honoured on read until the first save (see
20
+ * `readSupportSettingsState`), so an install that had it enabled keeps it.
21
+ */
7
22
 
8
23
  /**
9
24
  * GET /api/support/round-robin-config — Get round-robin enabled status
@@ -18,18 +33,9 @@ export function createRoundRobinConfigGetEndpoint(slugs: CollectionSlugs): Endpo
18
33
 
19
34
  requireAdmin(req, slugs)
20
35
 
21
- const prefs = await dbFind(payload, 'payload-preferences', {
22
- where: { key: { equals: PREF_KEY } },
23
- limit: 1,
24
- depth: 0,
25
- overrideAccess: true,
26
- })
27
-
28
- const enabled = prefs.docs.length > 0
29
- ? (prefs.docs[0].value as { enabled?: boolean })?.enabled === true
30
- : false
36
+ const { settings } = await readSupportSettingsState(payload)
31
37
 
32
- return Response.json({ enabled })
38
+ return Response.json({ enabled: settings.features.roundRobin })
33
39
  } catch (error) {
34
40
  const authResponse = handleAuthError(error)
35
41
  if (authResponse) return authResponse
@@ -55,19 +61,21 @@ export function createRoundRobinConfigPostEndpoint(slugs: CollectionSlugs): Endp
55
61
 
56
62
  const { enabled } = (await req.json!()) as { enabled: boolean }
57
63
 
58
- const existing = await dbFind(payload, 'payload-preferences', {
59
- where: { key: { equals: PREF_KEY } },
60
- limit: 1,
61
- depth: 0,
62
- overrideAccess: true,
63
- })
64
+ const { settings: current } = await readSupportSettingsState(payload)
65
+ const merged = mergeSupportSettings(
66
+ { features: { ...current.features, roundRobin: !!enabled } },
67
+ current,
68
+ )
64
69
 
65
70
  await payload.db.upsert({
66
71
  collection: 'payload-preferences',
67
72
  data: {
68
73
  key: PREF_KEY,
69
74
  user: { relationTo: req.user!.collection, value: req.user!.id },
70
- value: { enabled: !!enabled },
75
+ value: {
76
+ ...merged,
77
+ features: stripProjectedFeatures(merged.features),
78
+ } as unknown as Record<string, unknown>,
71
79
  },
72
80
  req: { payload, user: req.user } as any,
73
81
  where: {
@@ -79,6 +87,8 @@ export function createRoundRobinConfigPostEndpoint(slugs: CollectionSlugs): Endp
79
87
  },
80
88
  })
81
89
 
90
+ invalidateSupportSettingsCache()
91
+
82
92
  return Response.json({ enabled: !!enabled })
83
93
  } catch (error) {
84
94
  const authResponse = handleAuthError(error)
@@ -1,7 +1,7 @@
1
1
  import type { Endpoint } from 'payload'
2
2
  import type { CollectionSlugs } from '../utils/slugs'
3
3
  import { requireAdmin, handleAuthError } from '../utils/auth'
4
- import { RateLimiter, type RateLimitStore } from '../utils/rateLimiter'
4
+ import { principalRateKey, RateLimiter, type RateLimitStore } from '../utils/rateLimiter'
5
5
  import { escapeHtml, emailWrapper, emailParagraph, emailButton } from '../utils/emailTemplate'
6
6
  import { readSupportSettings } from '../utils/readSettings'
7
7
  import { dbFindByID, dbFind, dbCreate, dbUpdate } from '../utils/db'
@@ -38,7 +38,7 @@ function formatFr(date: Date, withTime: boolean): string {
38
38
  * Admin-only, rate-limited.
39
39
  */
40
40
  export function createSendReminderEndpoint(slugs: CollectionSlugs, store?: RateLimitStore): Endpoint {
41
- const reminderLimiter = new RateLimiter(60 * 60 * 1000, 30, store)
41
+ const reminderLimiter = new RateLimiter(60 * 60 * 1000, 30, store, 'send-reminder')
42
42
  return {
43
43
  path: '/support/send-reminder',
44
44
  method: 'post',
@@ -48,7 +48,7 @@ export function createSendReminderEndpoint(slugs: CollectionSlugs, store?: RateL
48
48
 
49
49
  requireAdmin(req, slugs)
50
50
 
51
- if (await reminderLimiter.check(String(req.user.id), req)) {
51
+ if (await reminderLimiter.check(principalRateKey(req.user), req)) {
52
52
  return Response.json(
53
53
  { error: 'Trop de relances. Réessayez dans une heure.' },
54
54
  { status: 429 },
@@ -1,59 +1,21 @@
1
1
  import type { Endpoint } from 'payload'
2
2
  import type { CollectionSlugs } from '../utils/slugs'
3
3
  import { requireAdmin, handleAuthError } from '../utils/auth'
4
- import { invalidateSupportSettingsCache } from '../utils/readSettings'
5
- import { dbFind } from '../utils/db'
6
-
7
- const PREF_KEY = 'support-settings'
8
-
9
- interface SupportSettings {
10
- email: {
11
- fromAddress: string
12
- fromName: string
13
- replyToAddress: string
14
- }
15
- ai: {
16
- provider: 'anthropic' | 'openai' | 'gemini' | 'ollama'
17
- model: string
18
- enableSentiment: boolean
19
- enableSynthesis: boolean
20
- enableSuggestion: boolean
21
- enableRewrite: boolean
22
- }
23
- sla: {
24
- firstResponseMinutes: number
25
- resolutionMinutes: number
26
- businessHoursOnly: boolean
27
- escalationEmail: string
28
- }
29
- autoClose: {
30
- enabled: boolean
31
- daysBeforeClose: number
32
- reminderDaysBefore: number
33
- }
34
- }
35
-
36
- const DEFAULT_SUPPORT_SETTINGS: SupportSettings = {
37
- email: { fromAddress: '', fromName: 'Support', replyToAddress: '' },
38
- ai: {
39
- provider: 'anthropic',
40
- model: 'claude-haiku-4-5-20251001',
41
- enableSentiment: true,
42
- enableSynthesis: true,
43
- enableSuggestion: true,
44
- enableRewrite: true,
45
- },
46
- sla: {
47
- firstResponseMinutes: 120,
48
- resolutionMinutes: 1440,
49
- businessHoursOnly: true,
50
- escalationEmail: '',
51
- },
52
- autoClose: { enabled: true, daysBeforeClose: 7, reminderDaysBefore: 2 },
53
- }
4
+ import {
5
+ SUPPORT_SETTINGS_PREF_KEY as PREF_KEY,
6
+ invalidateSupportSettingsCache,
7
+ mergeSupportSettings,
8
+ readSupportSettingsState,
9
+ type SupportSettings,
10
+ } from '../utils/readSettings'
11
+ import { stripProjectedFeatures } from '../utils/features'
54
12
 
55
13
  /**
56
14
  * GET /api/support/settings — Read support settings
15
+ *
16
+ * `featuresConfigured` tells the admin UI whether the server has ever had
17
+ * feature flags saved; a client still holding pre-2.1 `localStorage` flags uses
18
+ * it to seed the server once instead of losing its configuration.
57
19
  */
58
20
  export function createSettingsGetEndpoint(slugs: CollectionSlugs): Endpoint {
59
21
  return {
@@ -65,25 +27,9 @@ export function createSettingsGetEndpoint(slugs: CollectionSlugs): Endpoint {
65
27
 
66
28
  requireAdmin(req, slugs)
67
29
 
68
- const prefs = await dbFind(payload, 'payload-preferences', {
69
- where: { key: { equals: PREF_KEY } },
70
- limit: 1,
71
- depth: 0,
72
- overrideAccess: true,
73
- })
74
-
75
- let settings = { ...DEFAULT_SUPPORT_SETTINGS }
76
- if (prefs.docs.length > 0) {
77
- const stored = prefs.docs[0].value as Partial<SupportSettings>
78
- settings = {
79
- email: { ...DEFAULT_SUPPORT_SETTINGS.email, ...stored.email },
80
- ai: { ...DEFAULT_SUPPORT_SETTINGS.ai, ...stored.ai },
81
- sla: { ...DEFAULT_SUPPORT_SETTINGS.sla, ...stored.sla },
82
- autoClose: { ...DEFAULT_SUPPORT_SETTINGS.autoClose, ...stored.autoClose },
83
- }
84
- }
30
+ const { settings, featuresConfigured } = await readSupportSettingsState(payload)
85
31
 
86
- return Response.json(settings)
32
+ return Response.json({ ...settings, featuresConfigured })
87
33
  } catch (error) {
88
34
  const authResponse = handleAuthError(error)
89
35
  if (authResponse) return authResponse
@@ -95,7 +41,11 @@ export function createSettingsGetEndpoint(slugs: CollectionSlugs): Endpoint {
95
41
  }
96
42
 
97
43
  /**
98
- * POST /api/support/settings — Save all support settings (admin-only)
44
+ * POST /api/support/settings — Save support settings (admin-only)
45
+ *
46
+ * The body is merged onto the CURRENT settings, not onto the defaults: the
47
+ * feature-flag screen posts `{ features }` alone, and that must not reset the
48
+ * email / AI / SLA blocks.
99
49
  */
100
50
  export function createSettingsPostEndpoint(slugs: CollectionSlugs): Endpoint {
101
51
  return {
@@ -109,19 +59,20 @@ export function createSettingsPostEndpoint(slugs: CollectionSlugs): Endpoint {
109
59
 
110
60
  const body = (await req.json!()) as Partial<SupportSettings>
111
61
 
112
- const merged: SupportSettings = {
113
- email: { ...DEFAULT_SUPPORT_SETTINGS.email, ...body.email },
114
- ai: { ...DEFAULT_SUPPORT_SETTINGS.ai, ...body.ai },
115
- sla: { ...DEFAULT_SUPPORT_SETTINGS.sla, ...body.sla },
116
- autoClose: { ...DEFAULT_SUPPORT_SETTINGS.autoClose, ...body.autoClose },
117
- }
62
+ const { settings: current } = await readSupportSettingsState(payload)
63
+ const merged = mergeSupportSettings(body, current)
118
64
 
119
65
  await payload.db.upsert({
120
66
  collection: 'payload-preferences',
121
67
  data: {
122
68
  key: PREF_KEY,
123
69
  user: { relationTo: req.user!.collection, value: req.user!.id },
124
- value: merged as unknown as Record<string, unknown>,
70
+ value: {
71
+ ...merged,
72
+ // Persist the flags without the two projected ones, so `features`
73
+ // and the `autoClose` block can never drift apart.
74
+ features: stripProjectedFeatures(merged.features),
75
+ } as unknown as Record<string, unknown>,
125
76
  },
126
77
  req: { payload, user: req.user } as any,
127
78
  where: {
@@ -135,7 +86,7 @@ export function createSettingsPostEndpoint(slugs: CollectionSlugs): Endpoint {
135
86
 
136
87
  invalidateSupportSettingsCache()
137
88
 
138
- return Response.json(merged)
89
+ return Response.json({ ...merged, featuresConfigured: true })
139
90
  } catch (error) {
140
91
  const authResponse = handleAuthError(error)
141
92
  if (authResponse) return authResponse
@@ -19,7 +19,14 @@ export function createSignatureGetEndpoint(slugs: CollectionSlugs): Endpoint {
19
19
  requireAdmin(req, slugs)
20
20
 
21
21
  const prefs = await dbFind(payload, 'payload-preferences', {
22
- where: { key: { equals: `${PREF_KEY}-${req.user.id}` } },
22
+ // Scope to the staff auth collection: `payload-preferences` accepts a
23
+ // write from ANY authenticated principal, and ids collide between auth
24
+ // collections — a support-client with the same id would otherwise own
25
+ // the `email-signature-<id>` row read back for the agent.
26
+ where: {
27
+ key: { equals: `${PREF_KEY}-${req.user.id}` },
28
+ 'user.relationTo': { equals: slugs.users },
29
+ },
23
30
  limit: 1,
24
31
  depth: 0,
25
32
  overrideAccess: true,
@@ -57,7 +64,7 @@ export function createSignaturePostEndpoint(slugs: CollectionSlugs): Endpoint {
57
64
  const key = `${PREF_KEY}-${req.user.id}`
58
65
 
59
66
  const existing = await dbFind(payload, 'payload-preferences', {
60
- where: { key: { equals: key } },
67
+ where: { key: { equals: key }, 'user.relationTo': { equals: slugs.users } },
61
68
  limit: 1,
62
69
  depth: 0,
63
70
  overrideAccess: true,
@@ -4,7 +4,7 @@ import { requireAdmin, handleAuthError } from '../utils/auth'
4
4
  import { generateTicketSynthesis } from '../utils/generateTicketSynthesis'
5
5
  import { dbFindByID } from '../utils/db'
6
6
  import type { SupportCapabilities } from '../types'
7
- import { RateLimiter, type RateLimitStore } from '../utils/rateLimiter'
7
+ import { principalRateKey, RateLimiter, type RateLimitStore } from '../utils/rateLimiter'
8
8
 
9
9
  /**
10
10
  * POST /api/support/ticket-synthesis?ticketId=X[&force=true]
@@ -23,14 +23,14 @@ export function createTicketSynthesisEndpoint(
23
23
  generator?: SupportCapabilities['aiSummaries'],
24
24
  store?: RateLimitStore,
25
25
  ): Endpoint {
26
- const limiter = new RateLimiter(60_000, 20, store)
26
+ const limiter = new RateLimiter(60_000, 20, store, 'ticket-synthesis')
27
27
  return {
28
28
  path: '/support/ticket-synthesis',
29
29
  method: 'post',
30
30
  handler: async (req) => {
31
31
  try {
32
32
  requireAdmin(req, slugs)
33
- if (await limiter.check(String(req.user!.id), req)) {
33
+ if (await limiter.check(principalRateKey(req.user), req)) {
34
34
  return Response.json({ error: 'Rate limit exceeded' }, { status: 429 })
35
35
  }
36
36
  const payload = req.payload
@@ -3,7 +3,7 @@ import type { CollectionSlugs } from '../utils/slugs'
3
3
  import { handleAuthError, AuthError } from '../utils/auth'
4
4
  import { escapeHtml, emailWrapper, emailButton, emailParagraph } from '../utils/emailTemplate'
5
5
  import { readSupportSettings } from '../utils/readSettings'
6
- import { RateLimiter, type RateLimitStore } from '../utils/rateLimiter'
6
+ import { principalRateKey, RateLimiter, type RateLimitStore } from '../utils/rateLimiter'
7
7
  import { dbFindByID, dbFind, dbCreate, dbCount } from '../utils/db'
8
8
 
9
9
  // Simple RFC-5322 style sanity check — same level of strictness as the
@@ -13,6 +13,21 @@ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
13
13
 
14
14
  const MAX_TRANSFERS_PER_DAY = 5
15
15
 
16
+ /**
17
+ * Per-USER ceiling, ticket-independent.
18
+ *
19
+ * Both existing quotas (the in-memory limiter keyed `<user>:<ticket>` and the
20
+ * `email-logs` count) are per TICKET, and a support-client can open as many
21
+ * tickets as they want — so "5 transfers / 24 h" resets on every new ticket and
22
+ * the endpoint is an unbounded outbound mailer: the operator's From address, a
23
+ * "Récapitulatif support" subject, and 1 000 characters chosen by the caller,
24
+ * to any mailbox. This second quota is what actually bounds the volume.
25
+ *
26
+ * Applied to support-clients only: staff already hold the whole mailing surface
27
+ * of the admin panel, and capping them here would break legitimate bulk recaps.
28
+ */
29
+ const MAX_TRANSFERS_PER_USER_PER_DAY = 15
30
+
16
31
  // In-memory fail-closed rate limiter — enforced even when the persistent
17
32
  // emailLogs-based check is unavailable (collection disabled). Prevents the
18
33
  // endpoint from becoming an unbounded outbound mailer.
@@ -33,7 +48,8 @@ const MAX_TRANSFERS_PER_DAY = 5
33
48
  * - Rate-limited to 5 transfers per ticket per 24h
34
49
  */
35
50
  export function createTransferTicketEndpoint(slugs: CollectionSlugs, store?: RateLimitStore): Endpoint {
36
- const transferLimiter = new RateLimiter(24 * 60 * 60 * 1000, MAX_TRANSFERS_PER_DAY, store)
51
+ const transferLimiter = new RateLimiter(24 * 60 * 60 * 1000, MAX_TRANSFERS_PER_DAY, store, 'transfer:ticket')
52
+ const transferUserLimiter = new RateLimiter(24 * 60 * 60 * 1000, MAX_TRANSFERS_PER_USER_PER_DAY, store, 'transfer:user')
37
53
  return {
38
54
  path: '/support/tickets/:id/transfer',
39
55
  method: 'post',
@@ -82,13 +98,22 @@ export function createTransferTicketEndpoint(slugs: CollectionSlugs, store?: Rat
82
98
 
83
99
  // Fail-closed in-memory rate limit (per user + ticket): enforced even
84
100
  // when the persistent emailLogs check below cannot run.
85
- if (await transferLimiter.check(`${req.user.id}:${ticketId}`, req)) {
101
+ if (await transferLimiter.check(`${principalRateKey(req.user)}:${ticketId}`, req)) {
86
102
  return Response.json(
87
103
  { error: `Limite atteinte (${MAX_TRANSFERS_PER_DAY} transferts par 24h sur ce ticket)` },
88
104
  { status: 429 },
89
105
  )
90
106
  }
91
107
 
108
+ // Ticket-independent quota — the per-ticket ones above reset on every
109
+ // newly created ticket, which a client can do at will.
110
+ if (!isAdmin && await transferUserLimiter.check(principalRateKey(req.user), req)) {
111
+ return Response.json(
112
+ { error: `Limite atteinte (${MAX_TRANSFERS_PER_USER_PER_DAY} transferts par 24h)` },
113
+ { status: 429 },
114
+ )
115
+ }
116
+
92
117
  // Rate limit: count transfers in last 24h for this ticket
93
118
  try {
94
119
  const since = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString()