@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,12 +26,13 @@ export function MergePanel({
26
26
  <div style={{ display: 'flex', gap: '8px', alignItems: 'center', marginBottom: '8px' }}>
27
27
  <input
28
28
  type="text"
29
+ aria-label="Numero du ticket cible"
29
30
  value={mergeTarget}
30
31
  onChange={(e) => { setMergeTarget(e.target.value); setMergeTargetInfo(null); setMergeError('') }}
31
32
  placeholder="TK-0001"
32
33
  style={{ ...s.input, width: '130px' }}
33
34
  />
34
- <button onClick={handleMergeLookup} style={{ ...s.outlineBtn('#ec4899'), fontSize: '12px', padding: '6px 14px' }}>
35
+ <button type="button" onClick={handleMergeLookup} style={{ ...s.outlineBtn('#ec4899'), fontSize: '12px', padding: '6px 14px' }}>
35
36
  Rechercher
36
37
  </button>
37
38
  {mergeError && <span style={{ fontSize: '12px', color: '#be185d', fontWeight: 600 }}>{mergeError}</span>}
@@ -41,7 +42,7 @@ export function MergePanel({
41
42
  <span style={{ fontSize: '13px', fontWeight: 600 }}>
42
43
  {mergeTargetInfo.ticketNumber} — {mergeTargetInfo.subject}
43
44
  </span>
44
- <button onClick={handleMerge} disabled={merging} style={{ ...s.btn('#ec4899', merging), color: '#fff', fontSize: '12px', padding: '6px 14px' }}>
45
+ <button type="button" onClick={handleMerge} disabled={merging} style={{ ...s.btn('#ec4899', merging), color: '#fff', fontSize: '12px', padding: '6px 14px' }}>
45
46
  {merging ? 'Fusion...' : 'Confirmer la fusion'}
46
47
  </button>
47
48
  </div>
@@ -76,6 +77,7 @@ export function ExtMessagePanel({
76
77
  <div style={{ padding: '14px 18px', borderRadius: '8px', backgroundColor: '#eef2ff', border: '1px solid #c7d2fe', marginBottom: '14px' }}>
77
78
  <h4 style={{ fontSize: '13px', fontWeight: 600, marginBottom: '10px', color: '#312e81' }}>Ajouter un message reçu (email, SMS, WhatsApp...)</h4>
78
79
  <textarea
80
+ aria-label="Contenu du message recu"
79
81
  value={extMsgBody}
80
82
  onChange={(e) => setExtMsgBody(e.target.value)}
81
83
  placeholder="Coller le contenu du message reçu..."
@@ -83,17 +85,18 @@ export function ExtMessagePanel({
83
85
  style={{ ...s.input, width: '100%', resize: 'vertical', marginBottom: '10px' }}
84
86
  />
85
87
  <div style={{ display: 'flex', gap: '8px', alignItems: 'center', flexWrap: 'wrap', marginBottom: '10px' }}>
86
- <select value={extMsgAuthor} onChange={(e) => setExtMsgAuthor(e.target.value as 'client' | 'admin')} style={{ ...s.input, fontSize: '12px' }}>
88
+ <select aria-label="Auteur du message recu" value={extMsgAuthor} onChange={(e) => setExtMsgAuthor(e.target.value as 'client' | 'admin')} style={{ ...s.input, fontSize: '12px' }}>
87
89
  <option value="client">Envoyé par le client</option>
88
90
  <option value="admin">Envoyé par le support</option>
89
91
  </select>
90
92
  <input
91
93
  type="datetime-local"
94
+ aria-label="Date du message recu"
92
95
  value={extMsgDate}
93
96
  onChange={(e) => setExtMsgDate(e.target.value)}
94
97
  style={{ ...s.input, fontSize: '12px' }}
95
98
  />
96
- <input ref={extFileInputRef} type="file" multiple onChange={handleExtFileChange} style={{ display: 'none' }} accept="image/*,.pdf,.doc,.docx,.txt,.zip" />
99
+ <input ref={extFileInputRef} type="file" multiple aria-label="Joindre des fichiers au message recu" onChange={handleExtFileChange} style={{ display: 'none' }} accept="image/*,.pdf,.doc,.docx,.txt,.zip" />
97
100
  <button type="button" onClick={() => extFileInputRef.current?.click()} style={{ ...s.ghostBtn('#6b7280'), fontSize: '12px', padding: '6px 12px' }}>
98
101
  + PJ
99
102
  </button>
@@ -108,7 +111,7 @@ export function ExtMessagePanel({
108
111
  ))}
109
112
  </div>
110
113
  )}
111
- <button onClick={handleSendExtMsg} disabled={sendingExtMsg || !extMsgBody.trim()} style={s.btn('#1d2b4d', sendingExtMsg || !extMsgBody.trim())}>
114
+ <button type="button" onClick={handleSendExtMsg} disabled={sendingExtMsg || !extMsgBody.trim()} style={s.btn('#1d2b4d', sendingExtMsg || !extMsgBody.trim())}>
112
115
  {sendingExtMsg ? 'Ajout...' : 'Ajouter (sans notification)'}
113
116
  </button>
114
117
  </div>
@@ -186,6 +189,7 @@ export function ReminderPanel({
186
189
  )}
187
190
 
188
191
  <button
192
+ type="button"
189
193
  onClick={handleSendReminder}
190
194
  disabled={reminderSending || !clientEmail}
191
195
  style={{ ...s.btn(C.orange, reminderSending || !clientEmail), color: '#fff', fontSize: '13px', padding: '8px 18px' }}
@@ -207,11 +211,12 @@ export function SnoozePanel({ snoozeSaving, handleSnooze }: SnoozePanelProps) {
207
211
  <div style={{ padding: '14px 18px', borderRadius: '8px', backgroundColor: '#eef8f7', border: '1px solid #b8dcda', marginBottom: '14px' }}>
208
212
  <h4 style={{ fontSize: '13px', fontWeight: 600, marginBottom: '10px', color: '#5b21b6' }}>Snooze — masquer temporairement</h4>
209
213
  <div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap', alignItems: 'center' }}>
210
- <button onClick={() => handleSnooze(1)} disabled={snoozeSaving} style={{ ...s.outlineBtn('#17807c', snoozeSaving), fontSize: '12px' }}>1 jour</button>
211
- <button onClick={() => handleSnooze(3)} disabled={snoozeSaving} style={{ ...s.outlineBtn('#17807c', snoozeSaving), fontSize: '12px' }}>3 jours</button>
212
- <button onClick={() => handleSnooze(7)} disabled={snoozeSaving} style={{ ...s.outlineBtn('#17807c', snoozeSaving), fontSize: '12px' }}>1 semaine</button>
214
+ <button type="button" onClick={() => handleSnooze(1)} disabled={snoozeSaving} style={{ ...s.outlineBtn('#17807c', snoozeSaving), fontSize: '12px' }}>1 jour</button>
215
+ <button type="button" onClick={() => handleSnooze(3)} disabled={snoozeSaving} style={{ ...s.outlineBtn('#17807c', snoozeSaving), fontSize: '12px' }}>3 jours</button>
216
+ <button type="button" onClick={() => handleSnooze(7)} disabled={snoozeSaving} style={{ ...s.outlineBtn('#17807c', snoozeSaving), fontSize: '12px' }}>1 semaine</button>
213
217
  <input
214
218
  type="datetime-local"
219
+ aria-label="Snoozer jusqu'a une date precise"
215
220
  onChange={(e) => { if (e.target.value) handleSnooze(null, e.target.value) }}
216
221
  style={{ ...s.input, fontSize: '12px' }}
217
222
  />
@@ -96,6 +96,7 @@ export function ClientHistory({
96
96
  {/* Notes */}
97
97
  <h5 style={{ fontSize: '12px', fontWeight: 600, color: C.textSecondary, marginBottom: '8px' }}>Notes internes</h5>
98
98
  <textarea
99
+ aria-label="Notes internes sur le client"
99
100
  value={clientNotes}
100
101
  onChange={(e) => onNotesChange(e.target.value)}
101
102
  rows={3}
@@ -90,6 +90,7 @@ function SingleCodeBlock({ lang, code }: { lang: string; code: string }) {
90
90
  </span>
91
91
  </div>
92
92
  <button
93
+ type="button"
93
94
  onClick={handleCopy}
94
95
  style={{
95
96
  background: 'none',
@@ -109,6 +109,7 @@ export function CodeBlockInserter({ onInsert, className, style }: CodeBlockInser
109
109
  <input
110
110
  ref={inputRef}
111
111
  type="text"
112
+ aria-label="Rechercher un langage"
112
113
  value={filter}
113
114
  onChange={(e) => setFilter(e.target.value)}
114
115
  onKeyDown={(e) => {
@@ -124,7 +125,6 @@ export function CodeBlockInserter({ onInsert, className, style }: CodeBlockInser
124
125
  border: '1px solid #334155',
125
126
  borderRadius: 6,
126
127
  color: '#e2e8f0',
127
- outline: 'none',
128
128
  boxSizing: 'border-box',
129
129
  }}
130
130
  />
@@ -48,6 +48,7 @@ export function QuickActions({
48
48
  <div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap', marginBottom: '16px', alignItems: 'center' }}>
49
49
  {statusTransitions.map((a) => (
50
50
  <button
51
+ type="button"
51
52
  key={a.status}
52
53
  onClick={() => onStatusChange(a.status)}
53
54
  disabled={statusUpdating}
@@ -57,22 +58,22 @@ export function QuickActions({
57
58
  </button>
58
59
  ))}
59
60
  {snoozeUntil && new Date(snoozeUntil) > new Date() && (
60
- <button onClick={onCancelSnooze} disabled={snoozeSaving} style={{ ...s.ghostBtn('#17807c', snoozeSaving), fontSize: '12px', padding: '5px 10px' }}>
61
+ <button type="button" onClick={onCancelSnooze} disabled={snoozeSaving} style={{ ...s.ghostBtn('#17807c', snoozeSaving), fontSize: '12px', padding: '5px 10px' }}>
61
62
  Annuler snooze
62
63
  </button>
63
64
  )}
64
65
  <span style={{ borderLeft: `1px solid ${C.border}`, height: '20px', margin: '0 4px' }} />
65
- <button onClick={onToggleMerge} style={s.ghostBtn('#be185d')}>Fusionner</button>
66
- <button onClick={onToggleExtMsg} style={s.ghostBtn('#4f46e5')}>+ Message reçu</button>
67
- <button onClick={onToggleSnooze} style={s.ghostBtn('#17807c')}>Snooze</button>
66
+ <button type="button" onClick={onToggleMerge} style={s.ghostBtn('#be185d')}>Fusionner</button>
67
+ <button type="button" onClick={onToggleExtMsg} style={s.ghostBtn('#4f46e5')}>+ Message reçu</button>
68
+ <button type="button" onClick={onToggleSnooze} style={s.ghostBtn('#17807c')}>Snooze</button>
68
69
  {showReminderButton && (
69
- <button onClick={onToggleReminder} style={s.ghostBtn('#ea580c')}>{'⏰'} Relancer</button>
70
+ <button type="button" onClick={onToggleReminder} style={s.ghostBtn('#ea580c')}>{'⏰'} Relancer</button>
70
71
  )}
71
- <button onClick={onNextTicket} style={s.ghostBtn('#16a34a')}>Ticket suivant</button>
72
+ <button type="button" onClick={onNextTicket} style={s.ghostBtn('#16a34a')}>Ticket suivant</button>
72
73
  {scheduledClose && (
73
74
  <span style={{ display: 'inline-flex', alignItems: 'center', gap: '8px', padding: '5px 10px', borderRadius: '6px', backgroundColor: '#fff7ed', border: '1px solid #fed7aa', fontSize: '12px', color: '#9a3412', fontWeight: 600 }}>
74
75
  {'⏰'} Fermeture auto le {scheduledClose}
75
- <button onClick={onCancelScheduledClose} disabled={cancelingClose} style={{ border: 'none', background: 'none', color: '#b91c1c', cursor: cancelingClose ? 'not-allowed' : 'pointer', fontWeight: 700, fontSize: '12px', textDecoration: 'underline', padding: 0, opacity: cancelingClose ? 0.5 : 1 }}>
76
+ <button type="button" onClick={onCancelScheduledClose} disabled={cancelingClose} style={{ border: 'none', background: 'none', color: '#b91c1c', cursor: cancelingClose ? 'not-allowed' : 'pointer', fontWeight: 700, fontSize: '12px', textDecoration: 'underline', padding: 0, opacity: cancelingClose ? 0.5 : 1 }}>
76
77
  {cancelingClose ? '...' : 'Annuler'}
77
78
  </button>
78
79
  </span>
@@ -90,6 +91,7 @@ export function QuickActions({
90
91
  Ticket suivant : <strong>{nextTicketInfo}</strong>
91
92
  </span>
92
93
  <button
94
+ type="button"
93
95
  onClick={() => { window.location.href = `/admin/support/ticket?id=${nextTicketId}` }}
94
96
  style={{ ...s.btn(C.statusResolved), color: C.white, fontSize: '12px', padding: '5px 14px' }}
95
97
  >
@@ -99,7 +101,7 @@ export function QuickActions({
99
101
  ) : (
100
102
  <span style={{ fontSize: '13px', color: '#166534', fontWeight: 700 }}>{nextTicketInfo}</span>
101
103
  )}
102
- <button onClick={onCloseNextTicket} style={{ border: 'none', background: 'none', color: C.textMuted, cursor: 'pointer', fontSize: '16px', fontWeight: 700, marginLeft: '8px' }}>&times;</button>
104
+ <button type="button" onClick={onCloseNextTicket} style={{ border: 'none', background: 'none', color: C.textMuted, cursor: 'pointer', fontSize: '16px', fontWeight: 700, marginLeft: '8px' }}>&times;</button>
103
105
  </div>
104
106
  )}
105
107
  </>
@@ -71,6 +71,7 @@ export function TicketHeader({
71
71
  )}
72
72
  <span style={{ marginLeft: 'auto', display: 'inline-flex', alignItems: 'center', gap: '4px' }}>
73
73
  <button
74
+ type="button"
74
75
  onClick={() => onCopyLink('admin')}
75
76
  title="Copier le lien admin"
76
77
  style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: '13px', opacity: copiedLink === 'admin' ? 1 : 0.5, padding: '2px 4px' }}
@@ -78,6 +79,7 @@ export function TicketHeader({
78
79
  {copiedLink === 'admin' ? '\u2705' : '\uD83D\uDCCB'}
79
80
  </button>
80
81
  <button
82
+ type="button"
81
83
  onClick={() => onCopyLink('client')}
82
84
  title="Copier le lien client"
83
85
  style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: '13px', opacity: copiedLink === 'client' ? 1 : 0.5, padding: '2px 4px' }}
@@ -34,6 +34,11 @@ export function TimeTrackingPanel({
34
34
  duration, setDuration, timeDescription, setTimeDescription,
35
35
  handleAddTime, addingTime, timeSuccess,
36
36
  }: TimeTrackingPanelProps) {
37
+ // The two `<label>`s of the manual entry row exist but were not associated
38
+ // with anything. useId, never a literal: the panel is one per ticket screen
39
+ // but the pattern has to hold if it is ever mounted twice.
40
+ const minutesId = React.useId()
41
+ const workDescriptionId = React.useId()
37
42
  const totalH = Math.floor(totalMinutes / 60)
38
43
  const totalM = totalMinutes % 60
39
44
 
@@ -63,6 +68,7 @@ export function TimeTrackingPanel({
63
68
  <div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
64
69
  {[-5, -1, 1, 5, 15, 30].map((m) => (
65
70
  <button
71
+ type="button"
66
72
  key={m}
67
73
  onClick={() => setTimerSeconds((p) => Math.max(0, p + m * 60))}
68
74
  style={{ width: Math.abs(m) >= 15 ? '32px' : '28px', height: '28px', borderRadius: '6px', border: '1px solid #e2e8f0', background: 'white', cursor: 'pointer', fontSize: Math.abs(m) >= 15 ? '12px' : '14px', fontWeight: 700, color: '#64748b' }}
@@ -75,28 +81,30 @@ export function TimeTrackingPanel({
75
81
  )}
76
82
 
77
83
  {!timerRunning && timerSeconds === 0 && (
78
- <button onClick={() => handleTimerStart(true)} style={{ ...s.btn('#dc2626', false), fontSize: '12px', padding: '6px 16px', display: 'flex', alignItems: 'center', gap: '6px' }}>
84
+ <button type="button" onClick={() => handleTimerStart(true)} style={{ ...s.btn('#dc2626', false), fontSize: '12px', padding: '6px 16px', display: 'flex', alignItems: 'center', gap: '6px' }}>
79
85
  &#9654; Démarrer
80
86
  </button>
81
87
  )}
82
88
  {timerRunning && (
83
- <button onClick={handleTimerStop} style={{ ...s.btn('#374151', false), fontSize: '12px', padding: '6px 16px', display: 'flex', alignItems: 'center', gap: '6px' }}>
89
+ <button type="button" onClick={handleTimerStop} style={{ ...s.btn('#374151', false), fontSize: '12px', padding: '6px 16px', display: 'flex', alignItems: 'center', gap: '6px' }}>
84
90
  &#9208; Pause
85
91
  </button>
86
92
  )}
87
93
  {!timerRunning && timerSeconds > 0 && (
88
94
  <>
89
- <button onClick={() => handleTimerStart(false)} style={{ ...s.btn('#dc2626', false), fontSize: '12px', padding: '6px 14px' }}>
95
+ <button type="button" onClick={() => handleTimerStart(false)} style={{ ...s.btn('#dc2626', false), fontSize: '12px', padding: '6px 14px' }}>
90
96
  &#9654; Reprendre
91
97
  </button>
92
98
  <input
93
99
  type="text"
100
+ aria-label="Description du temps en cours"
94
101
  value={timerDescription}
95
102
  onChange={(e) => setTimerDescription(e.target.value)}
96
103
  placeholder="Description..."
97
104
  style={{ ...s.input, fontSize: '12px', flex: 1, minWidth: '120px' }}
98
105
  />
99
106
  <button
107
+ type="button"
100
108
  onClick={handleTimerSave}
101
109
  disabled={addingTime || timerSeconds < 60}
102
110
  style={{ ...s.btn('#16a34a', addingTime || timerSeconds < 60), fontSize: '12px', padding: '6px 14px' }}
@@ -104,7 +112,7 @@ export function TimeTrackingPanel({
104
112
  >
105
113
  &#128190; {Math.round(timerSeconds / 60)} min
106
114
  </button>
107
- <button onClick={handleTimerDiscard} style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: '14px', color: '#94a3b8', padding: '4px' }} title="Annuler">
115
+ <button type="button" onClick={handleTimerDiscard} style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: '14px', color: '#94a3b8', padding: '4px' }} title="Annuler">
108
116
  &#10005;
109
117
  </button>
110
118
  </>
@@ -119,14 +127,14 @@ export function TimeTrackingPanel({
119
127
  {/* Manual time entry */}
120
128
  <div style={{ display: 'flex', gap: '8px', alignItems: 'flex-end', flexWrap: 'wrap', marginBottom: '14px' }}>
121
129
  <div>
122
- <label style={{ display: 'block', fontSize: '11px', color: C.textSecondary, marginBottom: '3px', fontWeight: 600 }}>Min</label>
123
- <input type="number" min="1" value={duration} onChange={(e) => setDuration(e.target.value)} placeholder="30" style={{ ...s.input, width: '80px' }} />
130
+ <label htmlFor={minutesId} style={{ display: 'block', fontSize: '11px', color: C.textSecondary, marginBottom: '3px', fontWeight: 600 }}>Min</label>
131
+ <input id={minutesId} type="number" min="1" value={duration} onChange={(e) => setDuration(e.target.value)} placeholder="30" style={{ ...s.input, width: '80px' }} />
124
132
  </div>
125
133
  <div style={{ flex: 1 }}>
126
- <label style={{ display: 'block', fontSize: '11px', color: C.textSecondary, marginBottom: '3px', fontWeight: 600 }}>Description</label>
127
- <input type="text" value={timeDescription} onChange={(e) => setTimeDescription(e.target.value)} placeholder="Travail effectué..." style={{ ...s.input, width: '100%' }} />
134
+ <label htmlFor={workDescriptionId} style={{ display: 'block', fontSize: '11px', color: C.textSecondary, marginBottom: '3px', fontWeight: 600 }}>Description</label>
135
+ <input id={workDescriptionId} type="text" value={timeDescription} onChange={(e) => setTimeDescription(e.target.value)} placeholder="Travail effectué..." style={{ ...s.input, width: '100%' }} />
128
136
  </div>
129
- <button onClick={handleAddTime} disabled={addingTime || !duration} style={s.btn(C.amber, addingTime || !duration)}>
137
+ <button type="button" onClick={handleAddTime} disabled={addingTime || !duration} style={s.btn(C.amber, addingTime || !duration)}>
130
138
  {addingTime ? '...' : '+ Temps'}
131
139
  </button>
132
140
  {timeSuccess && <span style={{ fontSize: '12px', color: '#16a34a', fontWeight: 600 }}>{timeSuccess}</span>}
@@ -24,9 +24,10 @@ import { useDocumentIdFromUrl } from './hooks/useDocumentIdFromUrl'
24
24
  import { useFeatures } from './hooks/useFeatures'
25
25
  import { SkeletonText } from './SkeletonText'
26
26
  import { RewriteDropdown } from './RewriteDropdown'
27
+ import { AdminErrorBoundary } from '../../views/shared/ErrorBoundary'
27
28
  import '../../styles/theme.css'
28
29
 
29
- const TicketConversation: React.FC = () => {
30
+ const TicketConversationInner: React.FC = () => {
30
31
  const { id } = useDocumentIdFromUrl()
31
32
  const { features } = useFeatures()
32
33
  const [messages, setMessages] = useState<Message[]>([])
@@ -416,6 +417,7 @@ const TicketConversation: React.FC = () => {
416
417
  <div style={{ flex: 1 }}>
417
418
  <input
418
419
  type="text"
420
+ aria-label="Rechercher dans la conversation"
419
421
  value={searchQuery}
420
422
  onChange={(e) => setSearchQuery(e.target.value)}
421
423
  placeholder="Rechercher..."
@@ -442,6 +444,7 @@ const TicketConversation: React.FC = () => {
442
444
  <>
443
445
  {showCollapse && hiddenCount > 0 && (
444
446
  <button
447
+ type="button"
445
448
  onClick={() => setMessagesCollapsed(false)}
446
449
  style={{
447
450
  background: 'none', border: `1px dashed ${C.border}`, borderRadius: '6px',
@@ -454,6 +457,7 @@ const TicketConversation: React.FC = () => {
454
457
  )}
455
458
  {!messagesCollapsed && filtered.length > 1 && !isSearching && (
456
459
  <button
460
+ type="button"
457
461
  onClick={() => setMessagesCollapsed(true)}
458
462
  style={{
459
463
  background: 'none', border: `1px dashed ${C.border}`, borderRadius: '6px',
@@ -501,6 +505,7 @@ const TicketConversation: React.FC = () => {
501
505
  <span style={s.badge(C.emailBg, C.orange)}>Email</span>
502
506
  ) : (
503
507
  <select
508
+ aria-label="Auteur du message"
504
509
  value={msg.authorType}
505
510
  onChange={(e) => handleToggleAuthor(msg.id, e.target.value === 'admin' ? 'client' : 'admin')}
506
511
  disabled={togglingAuthor === msg.id}
@@ -537,6 +542,7 @@ const TicketConversation: React.FC = () => {
537
542
  {/* Split button */}
538
543
  {!msg.isInternal && !(msg as unknown as { deletedAt?: string }).deletedAt && (
539
544
  <button
545
+ type="button"
540
546
  onClick={() => handleSplitMessage(msg.id, ticketSubject)}
541
547
  style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: '10px', color: '#6b7280', padding: 0 }}
542
548
  title="Extraire en nouveau ticket"
@@ -579,16 +585,17 @@ const TicketConversation: React.FC = () => {
579
585
  {editingMsg === msg.id ? (
580
586
  <div style={{ marginTop: '6px' }}>
581
587
  <textarea
588
+ aria-label="Editer le message"
582
589
  value={editBody}
583
590
  onChange={(e) => { setEditBody(e.target.value); setEditHtml(e.target.value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br />')) }}
584
591
  rows={4}
585
592
  style={{ ...s.input, width: '100%', resize: 'vertical', fontSize: '13px' }}
586
593
  />
587
594
  <div style={{ display: 'flex', gap: '8px', marginTop: '8px' }}>
588
- <button onClick={() => handleEditSave(msg.id)} disabled={savingEdit || (!editBody.trim() && !editHtml)} style={{ ...s.btn(C.blue, savingEdit), fontSize: '11px', padding: '5px 12px' }}>
595
+ <button type="button" onClick={() => handleEditSave(msg.id)} disabled={savingEdit || (!editBody.trim() && !editHtml)} style={{ ...s.btn(C.blue, savingEdit), fontSize: '11px', padding: '5px 12px' }}>
589
596
  {savingEdit ? '...' : 'Enregistrer'}
590
597
  </button>
591
- <button onClick={handleEditCancel} style={{ ...s.ghostBtn('#6b7280'), fontSize: '11px', padding: '5px 12px' }}>
598
+ <button type="button" onClick={handleEditCancel} style={{ ...s.ghostBtn('#6b7280'), fontSize: '11px', padding: '5px 12px' }}>
592
599
  Annuler
593
600
  </button>
594
601
  </div>
@@ -775,6 +782,7 @@ const TicketConversation: React.FC = () => {
775
782
  ))}
776
783
  {features.ai && (
777
784
  <button
785
+ type="button"
778
786
  onClick={handleAiSuggestReply}
779
787
  disabled={aiReplying || messages.length === 0}
780
788
  style={{ ...s.outlineBtn('#17807c', aiReplying || messages.length === 0), fontSize: '11px', padding: '3px 10px', borderRadius: '14px' }}
@@ -800,7 +808,7 @@ const TicketConversation: React.FC = () => {
800
808
  />
801
809
  {features.canned && cannedResponses.length > 0 && (
802
810
  <>
803
- <select onChange={handleCannedSelect} style={{ ...s.input, fontSize: '11px', padding: '3px 8px', fontWeight: 600 }}>
811
+ <select aria-label="Reponse rapide" onChange={handleCannedSelect} style={{ ...s.input, fontSize: '11px', padding: '3px 8px', fontWeight: 600 }}>
804
812
  <option value="">Réponse rapide...</option>
805
813
  {cannedResponses.map((cr) => (
806
814
  <option key={cr.id} value={String(cr.id)}>{cr.title}</option>
@@ -818,6 +826,7 @@ const TicketConversation: React.FC = () => {
818
826
  {/* Reply textarea (replacing RichTextEditor) */}
819
827
  <div style={{ border: `1px solid ${C.border}`, borderRadius: '8px', overflow: 'hidden' }}>
820
828
  <textarea
829
+ aria-label="Reponse au client"
821
830
  value={replyBody}
822
831
  onChange={(e) => {
823
832
  const text = e.target.value
@@ -831,7 +840,6 @@ const TicketConversation: React.FC = () => {
831
840
  minHeight: '120px',
832
841
  padding: '12px',
833
842
  border: 'none',
834
- outline: 'none',
835
843
  fontSize: '14px',
836
844
  lineHeight: 1.5,
837
845
  resize: 'vertical',
@@ -848,6 +856,7 @@ const TicketConversation: React.FC = () => {
848
856
  ref={fileInputRef}
849
857
  type="file"
850
858
  multiple
859
+ aria-label="Joindre des fichiers"
851
860
  onChange={handleReplyFileChange}
852
861
  style={{ display: 'none' }}
853
862
  accept="image/*,.pdf,.doc,.docx,.txt,.zip"
@@ -866,6 +875,7 @@ const TicketConversation: React.FC = () => {
866
875
  {'\uD83D\uDCCE'} {file.name}
867
876
  <button
868
877
  type="button"
878
+ aria-label={`Retirer la piece jointe ${file.name}`}
869
879
  onClick={() => setReplyFiles((prev) => prev.filter((_, idx) => idx !== i))}
870
880
  style={{ border: 'none', background: 'none', color: '#ef4444', fontWeight: 700, cursor: 'pointer', fontSize: '14px', lineHeight: 1 }}
871
881
  >
@@ -879,6 +889,7 @@ const TicketConversation: React.FC = () => {
879
889
  {/* Send row */}
880
890
  <div style={{ display: 'flex', alignItems: 'center', gap: '10px', flexWrap: 'wrap', marginTop: '8px' }}>
881
891
  <select
892
+ aria-label="Envoyer en tant que"
882
893
  value={sendAsClient ? 'client' : 'admin'}
883
894
  onChange={(e) => setSendAsClient(e.target.value === 'client')}
884
895
  style={{ ...s.input, fontSize: '12px', padding: '6px 8px', fontWeight: 600 }}
@@ -896,11 +907,12 @@ const TicketConversation: React.FC = () => {
896
907
  Envoyer au client
897
908
  </label>
898
909
  )}
899
- <button data-action="send-reply" onClick={handleSendReply} disabled={sending || (!replyBody.trim() && !replyHtml)} style={{ ...s.btn(isInternal ? C.amber : notifyClient ? '#16a34a' : C.blue, sending || (!replyBody.trim() && !replyHtml)), fontSize: '13px', padding: '8px 20px', marginLeft: 'auto' }}>
910
+ <button type="button" data-action="send-reply" onClick={handleSendReply} disabled={sending || (!replyBody.trim() && !replyHtml)} style={{ ...s.btn(isInternal ? C.amber : notifyClient ? '#16a34a' : C.blue, sending || (!replyBody.trim() && !replyHtml)), fontSize: '13px', padding: '8px 20px', marginLeft: 'auto' }}>
900
911
  {sending ? 'Envoi...' : isInternal ? 'Ajouter note' : notifyClient ? 'Envoyer + Notifier' : 'Sauvegarder'}
901
912
  </button>
902
913
  {!isInternal && (
903
914
  <button
915
+ type="button"
904
916
  onClick={() => setShowSchedule(!showSchedule)}
905
917
  disabled={!replyBody.trim() && !replyHtml}
906
918
  style={{ ...s.outlineBtn('#17807c', !replyBody.trim() && !replyHtml), fontSize: '12px', padding: '8px 12px' }}
@@ -915,19 +927,21 @@ const TicketConversation: React.FC = () => {
915
927
  <span style={{ fontSize: '12px', fontWeight: 600, color: '#17807c' }}>Programmer pour :</span>
916
928
  <input
917
929
  type="datetime-local"
930
+ aria-label="Date et heure d'envoi programme"
918
931
  value={scheduleDate}
919
932
  onChange={(e) => setScheduleDate(e.target.value)}
920
933
  min={new Date().toISOString().slice(0, 16)}
921
934
  style={{ ...s.input, fontSize: '12px', width: 'auto' }}
922
935
  />
923
936
  <button
937
+ type="button"
924
938
  onClick={handleScheduleReply}
925
939
  disabled={sending || !scheduleDate || (!replyBody.trim() && !replyHtml)}
926
940
  style={{ ...s.btn('#17807c', sending || !scheduleDate || (!replyBody.trim() && !replyHtml)), fontSize: '12px', padding: '6px 14px' }}
927
941
  >
928
942
  {sending ? '...' : '\u23F0 Programmer'}
929
943
  </button>
930
- <button onClick={() => setShowSchedule(false)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#94a3b8', fontSize: '14px' }}>{'\u2715'}</button>
944
+ <button type="button" aria-label="Annuler la programmation" onClick={() => setShowSchedule(false)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#94a3b8', fontSize: '14px' }}>{'\u2715'}</button>
931
945
  </div>
932
946
  )}
933
947
  <div style={{ fontSize: '11px', color: C.textMuted, marginTop: '4px', textAlign: 'right' }}>
@@ -1088,4 +1102,24 @@ const TicketConversation: React.FC = () => {
1088
1102
 
1089
1103
 
1090
1104
 
1105
+ /**
1106
+ * Payload mounts this component straight from the import map as the `ui` field
1107
+ * of the Tickets edit view, so the plugin never gets to render an ancestor for
1108
+ * it: the boundary has to live inside the module itself. Without it a single
1109
+ * render error in any of the twelve sub-components blanks the whole ticket
1110
+ * screen.
1111
+ *
1112
+ * The document id is used as a reset key so that navigating from a ticket that
1113
+ * broke to another one clears the error on its own.
1114
+ */
1115
+ const TicketConversation: React.FC = () => {
1116
+ const { id } = useDocumentIdFromUrl()
1117
+
1118
+ return (
1119
+ <AdminErrorBoundary viewName="TicketConversation" resetKeys={[id]}>
1120
+ <TicketConversationInner />
1121
+ </AdminErrorBoundary>
1122
+ )
1123
+ }
1124
+
1091
1125
  export default TicketConversation
@@ -4,7 +4,7 @@ import crypto, { createHmac } from 'crypto'
4
4
  import { RateLimiter, type RateLimitStore } from '../utils/rateLimiter'
5
5
  import { escapeHtml } from '../utils/emailTemplate'
6
6
  import { dbFind, dbUpdate } from '../utils/db'
7
- import { verifyTwoFactorChallenge } from '../utils/twoFactorChallenge'
7
+ import { issueTwoFactorChallenge, normalizeEmail, verifyTwoFactorChallenge } from '../utils/twoFactorChallenge'
8
8
 
9
9
  function generateSecureCode(): string {
10
10
  const buf = crypto.randomBytes(4)
@@ -46,7 +46,35 @@ export function createAuth2faEndpoint(slugs: CollectionSlugs, store?: RateLimitS
46
46
  return Response.json({ error: 'Paramètres manquants' }, { status: 400 })
47
47
  }
48
48
 
49
- const genericSendResponse = { success: true, message: 'Si un compte existe, un code a été envoyé.' }
49
+ // Both limiters are keyed on the SAME normalized address the challenge is
50
+ // signed over. Keyed on the raw input, one challenge bought one budget per
51
+ // casing variant of the address it was minted for.
52
+ const limiterKey = normalizeEmail(email)
53
+
54
+ /**
55
+ * A challenge is a 10-minute proof that the password step succeeded. It
56
+ * is refreshed on every accepted `send` so it outlives exactly as long as
57
+ * the code that send just minted: without this, a resend late in the
58
+ * window produced a code whose challenge expired before the user could
59
+ * type it, and `verify` answered 401 on a perfectly valid code.
60
+ *
61
+ * Returned on EVERY `send` reply, including the throttled and the
62
+ * unknown-address ones, so the response shape never tells the caller
63
+ * whether the account exists.
64
+ */
65
+ const sendResponse = (): Response => {
66
+ let refreshed: string | undefined
67
+ try {
68
+ refreshed = issueTwoFactorChallenge(email)
69
+ } catch {
70
+ // PAYLOAD_SECRET vanished mid-flight: answer without a challenge.
71
+ }
72
+ return Response.json({
73
+ success: true,
74
+ message: 'Si un compte existe, un code a été envoyé.',
75
+ ...(refreshed ? { challenge: refreshed } : {}),
76
+ })
77
+ }
50
78
 
51
79
  if (action === 'send') {
52
80
  // The challenge is checked BEFORE the limiter on purpose: the limiter
@@ -60,8 +88,8 @@ export function createAuth2faEndpoint(slugs: CollectionSlugs, store?: RateLimitS
60
88
  )
61
89
  }
62
90
 
63
- if (await sendLimiter.check(email, req)) {
64
- return Response.json(genericSendResponse)
91
+ if (await sendLimiter.check(limiterKey, req)) {
92
+ return sendResponse()
65
93
  }
66
94
 
67
95
  const clients = await dbFind(payload, slugs.supportClients, {
@@ -72,7 +100,7 @@ export function createAuth2faEndpoint(slugs: CollectionSlugs, store?: RateLimitS
72
100
  })
73
101
 
74
102
  if (clients.docs.length === 0) {
75
- return Response.json(genericSendResponse)
103
+ return sendResponse()
76
104
  }
77
105
 
78
106
  const client = clients.docs[0] as any
@@ -101,15 +129,29 @@ export function createAuth2faEndpoint(slugs: CollectionSlugs, store?: RateLimitS
101
129
  </div>`,
102
130
  })
103
131
 
104
- return Response.json(genericSendResponse)
132
+ return sendResponse()
105
133
  }
106
134
 
107
135
  if (action === 'verify') {
136
+ // Same proof, same reason, same position as `send` above: the limiter
137
+ // below is keyed on the VICTIM's email, so an unauthenticated caller
138
+ // able to reach it could burn the 5 attempts and leave the victim —
139
+ // holding the right password AND the right code — locked out of the
140
+ // only route that clears the 2FA gate, renewably, every 15 minutes.
141
+ // Only `send` got this guard in the previous pass; `verify` is the
142
+ // other half of the same door.
143
+ if (!verifyTwoFactorChallenge(email, challenge)) {
144
+ return Response.json(
145
+ { error: 'Authentification requise avant la vérification d\'un code.' },
146
+ { status: 401 },
147
+ )
148
+ }
149
+
108
150
  if (!code) {
109
151
  return Response.json({ error: 'Code manquant' }, { status: 400 })
110
152
  }
111
153
 
112
- if (await verifyLimiter.check(email, req)) {
154
+ if (await verifyLimiter.check(limiterKey, req)) {
113
155
  return Response.json(
114
156
  { error: 'Trop de tentatives. Réessayez dans 15 minutes.' },
115
157
  { status: 429 },
@@ -159,7 +201,10 @@ export function createAuth2faEndpoint(slugs: CollectionSlugs, store?: RateLimitS
159
201
  overrideAccess: true,
160
202
  })
161
203
 
162
- verifyLimiter.reset(email)
204
+ // `req` is REQUIRED here: PayloadRateLimitStore throws without it, and
205
+ // this reset runs after the marker was written — a throw turned a
206
+ // successful verification into a 500 for the caller.
207
+ await verifyLimiter.reset(limiterKey, req)
163
208
 
164
209
  return Response.json({ success: true, verified: true })
165
210
  }
@@ -1,7 +1,7 @@
1
1
  import type { Endpoint, Where } from 'payload'
2
2
  import type { SupportCapabilities } from '../types'
3
3
  import type { CollectionSlugs } from '../utils/slugs'
4
- import { principalRateKey, RateLimiter, type RateLimitStore } from '../utils/rateLimiter'
4
+ import { clientIpRateKey, principalRateKey, RateLimiter, type RateLimitStore } from '../utils/rateLimiter'
5
5
  import {
6
6
  validateInboundEmailPayload,
7
7
  verifySecret,
@@ -21,9 +21,7 @@ export function createInboundEmailEndpoint(
21
21
  if (!verifySecret(req.headers.get(secretHeader), capability.secret)) {
22
22
  return Response.json({ error: 'Unauthorized' }, { status: 401 })
23
23
  }
24
- const ip = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
25
- || req.headers.get('x-real-ip')
26
- || 'unknown'
24
+ const ip = clientIpRateKey(req)
27
25
  if (await limiter.check(ip, req)) {
28
26
  return Response.json({ error: 'Rate limit exceeded' }, { status: 429 })
29
27
  }
@@ -1,6 +1,6 @@
1
1
  import type { Endpoint } from 'payload'
2
2
  import type { CollectionSlugs } from '../utils/slugs'
3
- import { RateLimiter, type RateLimitStore } from '../utils/rateLimiter'
3
+ import { clientIpRateKey, RateLimiter, type RateLimitStore } from '../utils/rateLimiter'
4
4
  import { dbFind } from '../utils/db'
5
5
 
6
6
 
@@ -41,7 +41,7 @@ export function createChatbotEndpoint(
41
41
  method: 'post',
42
42
  handler: async (req) => {
43
43
  try {
44
- const ip = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || req.headers.get('x-real-ip') || 'unknown'
44
+ const ip = clientIpRateKey(req)
45
45
  if (await chatbotLimiter.check(ip, req)) {
46
46
  return Response.json({ error: 'Too many requests. Please wait a moment.' }, { status: 429 })
47
47
  }