@consilioweb/payload-support 1.1.0 → 2.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 (99) hide show
  1. package/README.md +4 -0
  2. package/dist/components/TicketConversation/components/AISummaryPanel.cjs +22 -14
  3. package/dist/components/TicketConversation/components/AISummaryPanel.js +23 -16
  4. package/dist/components/TicketConversation/components/ActionPanels.cjs +5 -5
  5. package/dist/components/TicketConversation/components/ActionPanels.js +5 -5
  6. package/dist/components/TicketConversation/components/ClientBar.cjs +1 -1
  7. package/dist/components/TicketConversation/components/ClientBar.js +1 -1
  8. package/dist/components/TicketConversation/components/QuickActions.cjs +2 -2
  9. package/dist/components/TicketConversation/components/QuickActions.js +2 -2
  10. package/dist/components/TicketConversation/components/TicketHeader.cjs +1 -1
  11. package/dist/components/TicketConversation/components/TicketHeader.js +1 -1
  12. package/dist/components/TicketConversation/index.cjs +10 -10
  13. package/dist/components/TicketConversation/index.js +10 -10
  14. package/dist/index.cjs +710 -174
  15. package/dist/index.d.cts +165 -3
  16. package/dist/index.d.ts +165 -3
  17. package/dist/index.js +703 -176
  18. package/dist/styles/ChatView.module.scss +1 -1
  19. package/dist/styles/TicketDetail.module.scss +3 -3
  20. package/dist/styles/_tokens.scss +1 -1
  21. package/dist/styles/theme.css +6 -6
  22. package/dist/views/TicketingSettingsView/client.cjs +3 -3
  23. package/dist/views/TicketingSettingsView/client.js +3 -3
  24. package/package.json +9 -9
  25. package/src/__tests__/aiSummaryRendering.test.ts +21 -0
  26. package/src/__tests__/authResponses.test.ts +69 -0
  27. package/src/__tests__/capabilities.test.ts +71 -0
  28. package/src/__tests__/generateTrackingToken.test.ts +18 -4
  29. package/src/__tests__/integration/core-behaviors.test.ts +23 -0
  30. package/src/__tests__/rateLimiter.test.ts +60 -66
  31. package/src/__tests__/trackOpenEndpoint.test.ts +54 -0
  32. package/src/__tests__/webhookSecurity.test.ts +58 -0
  33. package/src/collections/PendingEmails.ts +2 -1
  34. package/src/collections/SupportClients.ts +43 -1
  35. package/src/collections/SupportCounters.ts +14 -0
  36. package/src/collections/SupportRateLimits.ts +20 -0
  37. package/src/collections/TicketMessages.ts +76 -0
  38. package/src/collections/Tickets.ts +183 -40
  39. package/src/collections/index.ts +2 -0
  40. package/src/components/TicketConversation/components/AISummaryPanel.tsx +26 -13
  41. package/src/components/TicketConversation/components/ActionPanels.tsx +5 -5
  42. package/src/components/TicketConversation/components/ClientBar.tsx +1 -1
  43. package/src/components/TicketConversation/components/QuickActions.tsx +2 -2
  44. package/src/components/TicketConversation/components/TicketHeader.tsx +1 -1
  45. package/src/components/TicketConversation/index.tsx +10 -10
  46. package/src/endpoints/admin-chat.ts +4 -4
  47. package/src/endpoints/ai-agent.ts +6 -1
  48. package/src/endpoints/ai.ts +10 -5
  49. package/src/endpoints/auth-2fa.ts +6 -7
  50. package/src/endpoints/auto-close.ts +2 -1
  51. package/src/endpoints/capabilities.ts +144 -0
  52. package/src/endpoints/channels.ts +2 -1
  53. package/src/endpoints/chat.ts +6 -6
  54. package/src/endpoints/chatbot.ts +4 -4
  55. package/src/endpoints/client-intelligence.ts +13 -4
  56. package/src/endpoints/import-conversation.ts +6 -5
  57. package/src/endpoints/index.ts +35 -14
  58. package/src/endpoints/invite-collaborator.ts +4 -4
  59. package/src/endpoints/login.ts +4 -5
  60. package/src/endpoints/oauth-google.ts +1 -1
  61. package/src/endpoints/process-digests.ts +2 -1
  62. package/src/endpoints/process-scheduled.ts +2 -1
  63. package/src/endpoints/process-snooze.ts +2 -1
  64. package/src/endpoints/resend-notification.ts +4 -4
  65. package/src/endpoints/send-reminder.ts +4 -4
  66. package/src/endpoints/ticket-synthesis.ts +17 -2
  67. package/src/endpoints/track-open.ts +44 -21
  68. package/src/endpoints/transfer-ticket.ts +4 -4
  69. package/src/index.ts +7 -0
  70. package/src/plugin.ts +16 -1
  71. package/src/portal/LiveChat.tsx +1 -1
  72. package/src/portal/auth/ChatWidget.tsx +4 -4
  73. package/src/portal/auth/ChatbotWidget.tsx +4 -4
  74. package/src/portal/auth/dashboard/DashboardClient.tsx +2 -2
  75. package/src/portal/auth/faq/FAQSearch.tsx +1 -1
  76. package/src/portal/auth/faq/page.tsx +2 -2
  77. package/src/portal/auth/profile/page.tsx +17 -17
  78. package/src/portal/auth/tickets/detail/CloseTicketButton.tsx +1 -1
  79. package/src/portal/auth/tickets/detail/MarkSolutionButton.tsx +1 -1
  80. package/src/portal/auth/tickets/detail/ReopenTicketButton.tsx +1 -1
  81. package/src/portal/auth/tickets/detail/SatisfactionForm.tsx +3 -3
  82. package/src/portal/auth/tickets/detail/TicketReplyForm.tsx +3 -3
  83. package/src/portal/auth/tickets/detail/page.tsx +5 -5
  84. package/src/portal/auth/tickets/new/page.tsx +8 -8
  85. package/src/portal/forgot-password/page.tsx +3 -3
  86. package/src/portal/layout.tsx +5 -2
  87. package/src/portal/login/page.tsx +7 -7
  88. package/src/portal/page.tsx +9 -9
  89. package/src/portal/register/page.tsx +4 -4
  90. package/src/portal/reset-password/page.tsx +3 -3
  91. package/src/styles/ChatView.module.scss +1 -1
  92. package/src/styles/TicketDetail.module.scss +3 -3
  93. package/src/styles/_tokens.scss +1 -1
  94. package/src/styles/theme.css +6 -6
  95. package/src/types.ts +71 -0
  96. package/src/utils/rateLimiter.ts +131 -38
  97. package/src/utils/slugs.ts +4 -0
  98. package/src/utils/webhookSecurity.ts +81 -0
  99. package/src/views/TicketingSettingsView/client.tsx +3 -3
@@ -1,4 +1,5 @@
1
1
  import React from 'react'
2
+ import { headers } from 'next/headers'
2
3
 
3
4
  export const metadata = {
4
5
  title: 'Support Client',
@@ -6,19 +7,21 @@ export const metadata = {
6
7
  robots: { index: false, follow: false },
7
8
  }
8
9
 
9
- export default function SupportRootLayout({ children }: { children: React.ReactNode }) {
10
+ export default async function SupportRootLayout({ children }: { children: React.ReactNode }) {
11
+ const nonce = (await headers()).get('x-nonce') || undefined
10
12
  return (
11
13
  <html lang="fr" suppressHydrationWarning>
12
14
  <head>
13
15
  <link href="/favicon.ico" rel="icon" sizes="32x32" />
14
16
  <meta name="theme-color" content="#2563eb" />
15
17
  <script
18
+ nonce={nonce}
16
19
  dangerouslySetInnerHTML={{
17
20
  __html: `(function(){try{if(localStorage.getItem('support-dark-mode')==='true'){document.documentElement.setAttribute('data-theme','dark')}}catch(e){console.error('[support-layout] Failed to get user:',e)}})()`,
18
21
  }}
19
22
  />
20
23
  </head>
21
- <body className="min-h-screen bg-gray-50 dark:bg-gray-950" suppressHydrationWarning>
24
+ <body className="min-h-[100dvh] bg-gray-50 dark:bg-gray-950" suppressHydrationWarning>
22
25
  {children}
23
26
  </body>
24
27
  </html>
@@ -145,7 +145,7 @@ function SupportLoginContent() {
145
145
  }
146
146
 
147
147
  return (
148
- <div className="flex min-h-screen flex-col items-center justify-center px-4 py-12 bg-gradient-to-br from-slate-50 via-white to-blue-50 dark:from-slate-950 dark:via-slate-950 dark:to-blue-950/20">
148
+ <div className="flex min-h-[100dvh] flex-col items-center justify-center px-4 py-12 bg-gradient-to-br from-slate-50 via-white to-blue-50 dark:from-slate-950 dark:via-slate-950 dark:to-blue-950/20">
149
149
  {/* Decorative background element */}
150
150
  <div className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_60%_50%_at_50%_0%,rgba(37,99,235,0.08),transparent)] dark:bg-[radial-gradient(ellipse_60%_50%_at_50%_0%,rgba(37,99,235,0.12),transparent)]" />
151
151
 
@@ -208,7 +208,7 @@ function SupportLoginContent() {
208
208
  value={twoFactorCode}
209
209
  onChange={(e) => setTwoFactorCode(e.target.value.replace(/\D/g, ''))}
210
210
  placeholder="000000"
211
- className="w-full rounded-xl border border-slate-300 bg-white px-4 py-3 text-center text-2xl font-semibold tracking-[0.4em] text-slate-900 outline-none transition-all placeholder:text-slate-300 focus:border-blue-500 focus:ring-4 focus:ring-blue-500/10 dark:border-slate-600 dark:bg-slate-900 dark:text-white dark:placeholder:text-slate-600 dark:focus:border-blue-500 dark:focus:ring-blue-500/20"
211
+ className="w-full rounded-xl border border-slate-300 bg-white px-4 py-3 text-center text-2xl font-semibold tracking-[0.4em] text-slate-900 outline-none transition-[color,background-color,border-color,box-shadow,transform] motion-reduce:transition-none placeholder:text-slate-300 focus:border-blue-500 focus:ring-4 focus:ring-blue-500/10 dark:border-slate-600 dark:bg-slate-900 dark:text-white dark:placeholder:text-slate-600 dark:focus:border-blue-500 dark:focus:ring-blue-500/20"
212
212
  autoFocus
213
213
  />
214
214
  </div>
@@ -216,7 +216,7 @@ function SupportLoginContent() {
216
216
  <button
217
217
  type="submit"
218
218
  disabled={loading || twoFactorCode.length !== 6}
219
- className="w-full rounded-xl bg-blue-600 px-6 py-3 text-sm font-medium text-white shadow-lg shadow-blue-600/25 transition-all hover:bg-blue-700 hover:shadow-xl active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-50 disabled:shadow-none"
219
+ className="w-full rounded-xl bg-blue-600 px-6 py-3 text-sm font-medium text-white shadow-lg shadow-blue-600/25 transition-[color,background-color,border-color,box-shadow,transform] motion-reduce:transition-none hover:bg-blue-700 hover:shadow-xl active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-50 disabled:shadow-none"
220
220
  >
221
221
  {loading ? 'Verification...' : 'Verifier'}
222
222
  </button>
@@ -274,7 +274,7 @@ function SupportLoginContent() {
274
274
  value={email}
275
275
  onChange={(e) => setEmail(e.target.value)}
276
276
  placeholder="vous@entreprise.fr"
277
- className="w-full rounded-xl border border-slate-300 bg-white px-4 py-3 text-sm text-slate-900 outline-none transition-all placeholder:text-slate-400 focus:border-blue-500 focus:ring-4 focus:ring-blue-500/10 dark:border-slate-600 dark:bg-slate-900 dark:text-white dark:placeholder:text-slate-500 dark:focus:border-blue-500 dark:focus:ring-blue-500/20"
277
+ className="w-full rounded-xl border border-slate-300 bg-white px-4 py-3 text-sm text-slate-900 outline-none transition-[color,background-color,border-color,box-shadow,transform] motion-reduce:transition-none placeholder:text-slate-400 focus:border-blue-500 focus:ring-4 focus:ring-blue-500/10 dark:border-slate-600 dark:bg-slate-900 dark:text-white dark:placeholder:text-slate-500 dark:focus:border-blue-500 dark:focus:ring-blue-500/20"
278
278
  />
279
279
  </div>
280
280
 
@@ -289,14 +289,14 @@ function SupportLoginContent() {
289
289
  value={password}
290
290
  onChange={(e) => setPassword(e.target.value)}
291
291
  placeholder="••••••••"
292
- className="w-full rounded-xl border border-slate-300 bg-white px-4 py-3 text-sm text-slate-900 outline-none transition-all placeholder:text-slate-400 focus:border-blue-500 focus:ring-4 focus:ring-blue-500/10 dark:border-slate-600 dark:bg-slate-900 dark:text-white dark:placeholder:text-slate-500 dark:focus:border-blue-500 dark:focus:ring-blue-500/20"
292
+ className="w-full rounded-xl border border-slate-300 bg-white px-4 py-3 text-sm text-slate-900 outline-none transition-[color,background-color,border-color,box-shadow,transform] motion-reduce:transition-none placeholder:text-slate-400 focus:border-blue-500 focus:ring-4 focus:ring-blue-500/10 dark:border-slate-600 dark:bg-slate-900 dark:text-white dark:placeholder:text-slate-500 dark:focus:border-blue-500 dark:focus:ring-blue-500/20"
293
293
  />
294
294
  </div>
295
295
 
296
296
  <button
297
297
  type="submit"
298
298
  disabled={loading || sending2FA}
299
- className="w-full rounded-xl bg-blue-600 px-6 py-3 text-sm font-medium text-white shadow-lg shadow-blue-600/25 transition-all hover:bg-blue-700 hover:shadow-xl active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-50 disabled:shadow-none"
299
+ className="w-full rounded-xl bg-blue-600 px-6 py-3 text-sm font-medium text-white shadow-lg shadow-blue-600/25 transition-[color,background-color,border-color,box-shadow,transform] motion-reduce:transition-none hover:bg-blue-700 hover:shadow-xl active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-50 disabled:shadow-none"
300
300
  >
301
301
  {loading || sending2FA ? 'Connexion...' : 'Se connecter'}
302
302
  </button>
@@ -312,7 +312,7 @@ function SupportLoginContent() {
312
312
  {/* eslint-disable-next-line @next/next/no-html-link-for-pages */}
313
313
  <a
314
314
  href="/api/support/oauth/google?action=login"
315
- className="flex w-full items-center justify-center gap-3 rounded-xl border border-slate-300 bg-white px-6 py-3 text-sm font-medium text-slate-700 transition-all hover:bg-slate-50 hover:shadow-md active:scale-[0.98] dark:border-slate-600 dark:bg-slate-900 dark:text-slate-300 dark:hover:bg-slate-800"
315
+ className="flex w-full items-center justify-center gap-3 rounded-xl border border-slate-300 bg-white px-6 py-3 text-sm font-medium text-slate-700 transition-[color,background-color,border-color,box-shadow,transform] motion-reduce:transition-none hover:bg-slate-50 hover:shadow-md active:scale-[0.98] dark:border-slate-600 dark:bg-slate-900 dark:text-slate-300 dark:hover:bg-slate-800"
316
316
  >
317
317
  <svg className="h-5 w-5" viewBox="0 0 24 24">
318
318
  <path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z" fill="#4285F4" />
@@ -6,7 +6,7 @@ const supportEmail = process.env.SUPPORT_EMAIL || 'support@example.com'
6
6
 
7
7
  export default function SupportPage() {
8
8
  return (
9
- <div className="flex min-h-screen flex-col bg-white dark:bg-slate-950">
9
+ <div className="flex min-h-[100dvh] flex-col bg-white dark:bg-slate-950">
10
10
  {/* Header */}
11
11
  <header className="border-b border-slate-200 bg-white/80 backdrop-blur-lg dark:border-slate-800 dark:bg-slate-950/80">
12
12
  <div className="mx-auto flex max-w-5xl items-center justify-between px-6 py-4">
@@ -20,7 +20,7 @@ export default function SupportPage() {
20
20
  </Link>
21
21
  <Link
22
22
  href="/support/login"
23
- className="rounded-lg bg-blue-600 px-5 py-2 text-sm font-medium text-white shadow-sm transition-all hover:bg-blue-700 hover:shadow-md active:scale-[0.98]"
23
+ className="rounded-lg bg-blue-600 px-5 py-2 text-sm font-medium text-white shadow-sm transition-[color,background-color,border-color,box-shadow,transform] motion-reduce:transition-none hover:bg-blue-700 hover:shadow-md active:scale-[0.98]"
24
24
  >
25
25
  Se connecter
26
26
  </Link>
@@ -49,7 +49,7 @@ export default function SupportPage() {
49
49
  <div className="mt-10 flex flex-col items-center gap-4 sm:flex-row sm:justify-center">
50
50
  <Link
51
51
  href="/support/login"
52
- className="inline-flex items-center gap-2 rounded-xl bg-blue-600 px-8 py-3.5 text-base font-medium text-white shadow-lg shadow-blue-600/25 transition-all hover:bg-blue-700 hover:shadow-xl hover:shadow-blue-600/30 active:scale-[0.98] dark:shadow-blue-600/20"
52
+ className="inline-flex items-center gap-2 rounded-xl bg-blue-600 px-8 py-3.5 text-base font-medium text-white shadow-lg shadow-blue-600/25 transition-[color,background-color,border-color,box-shadow,transform] motion-reduce:transition-none hover:bg-blue-700 hover:shadow-xl hover:shadow-blue-600/30 active:scale-[0.98] dark:shadow-blue-600/20"
53
53
  >
54
54
  Accéder à mon espace
55
55
  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="h-5 w-5">
@@ -58,7 +58,7 @@ export default function SupportPage() {
58
58
  </Link>
59
59
  <Link
60
60
  href="/support/register"
61
- className="inline-flex items-center rounded-xl border border-slate-300 bg-white px-8 py-3.5 text-base font-medium text-slate-700 shadow-sm transition-all hover:bg-slate-50 hover:shadow-md active:scale-[0.98] dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700"
61
+ className="inline-flex items-center rounded-xl border border-slate-300 bg-white px-8 py-3.5 text-base font-medium text-slate-700 shadow-sm transition-[color,background-color,border-color,box-shadow,transform] motion-reduce:transition-none hover:bg-slate-50 hover:shadow-md active:scale-[0.98] dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700"
62
62
  >
63
63
  Créer un compte
64
64
  </Link>
@@ -80,7 +80,7 @@ export default function SupportPage() {
80
80
  </p>
81
81
  <div className="grid gap-8 sm:grid-cols-3">
82
82
  {/* Card 1 */}
83
- <div className="group rounded-2xl border border-slate-200 bg-white p-8 shadow-sm transition-all hover:shadow-lg hover:-translate-y-0.5 dark:border-slate-700 dark:bg-slate-800">
83
+ <div className="group rounded-2xl border border-slate-200 bg-white p-8 shadow-sm transition-[color,background-color,border-color,box-shadow,transform] motion-reduce:transition-none hover:shadow-lg hover:-translate-y-0.5 dark:border-slate-700 dark:bg-slate-800">
84
84
  <div className="mb-5 flex h-12 w-12 items-center justify-center rounded-xl bg-blue-100 text-blue-600 transition-colors group-hover:bg-blue-600 group-hover:text-white dark:bg-blue-950 dark:text-blue-400 dark:group-hover:bg-blue-600 dark:group-hover:text-white">
85
85
  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="h-6 w-6">
86
86
  <path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z" />
@@ -96,7 +96,7 @@ export default function SupportPage() {
96
96
  </div>
97
97
 
98
98
  {/* Card 2 */}
99
- <div className="group rounded-2xl border border-slate-200 bg-white p-8 shadow-sm transition-all hover:shadow-lg hover:-translate-y-0.5 dark:border-slate-700 dark:bg-slate-800">
99
+ <div className="group rounded-2xl border border-slate-200 bg-white p-8 shadow-sm transition-[color,background-color,border-color,box-shadow,transform] motion-reduce:transition-none hover:shadow-lg hover:-translate-y-0.5 dark:border-slate-700 dark:bg-slate-800">
100
100
  <div className="mb-5 flex h-12 w-12 items-center justify-center rounded-xl bg-blue-100 text-blue-600 transition-colors group-hover:bg-blue-600 group-hover:text-white dark:bg-blue-950 dark:text-blue-400 dark:group-hover:bg-blue-600 dark:group-hover:text-white">
101
101
  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="h-6 w-6">
102
102
  <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
@@ -109,7 +109,7 @@ export default function SupportPage() {
109
109
  </div>
110
110
 
111
111
  {/* Card 3 */}
112
- <div className="group rounded-2xl border border-slate-200 bg-white p-8 shadow-sm transition-all hover:shadow-lg hover:-translate-y-0.5 dark:border-slate-700 dark:bg-slate-800">
112
+ <div className="group rounded-2xl border border-slate-200 bg-white p-8 shadow-sm transition-[color,background-color,border-color,box-shadow,transform] motion-reduce:transition-none hover:shadow-lg hover:-translate-y-0.5 dark:border-slate-700 dark:bg-slate-800">
113
113
  <div className="mb-5 flex h-12 w-12 items-center justify-center rounded-xl bg-blue-100 text-blue-600 transition-colors group-hover:bg-blue-600 group-hover:text-white dark:bg-blue-950 dark:text-blue-400 dark:group-hover:bg-blue-600 dark:group-hover:text-white">
114
114
  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="h-6 w-6">
115
115
  <circle cx="12" cy="12" r="10" />
@@ -139,13 +139,13 @@ export default function SupportPage() {
139
139
  <div className="flex flex-col items-center gap-4 sm:flex-row sm:justify-center">
140
140
  <Link
141
141
  href="/support/register"
142
- className="inline-flex items-center rounded-xl bg-blue-600 px-8 py-3.5 text-base font-medium text-white shadow-lg shadow-blue-600/25 transition-all hover:bg-blue-700 hover:shadow-xl active:scale-[0.98]"
142
+ className="inline-flex items-center rounded-xl bg-blue-600 px-8 py-3.5 text-base font-medium text-white shadow-lg shadow-blue-600/25 transition-[color,background-color,border-color,box-shadow,transform] motion-reduce:transition-none hover:bg-blue-700 hover:shadow-xl active:scale-[0.98]"
143
143
  >
144
144
  Créer un compte
145
145
  </Link>
146
146
  <a
147
147
  href={`mailto:${supportEmail}`}
148
- className="inline-flex items-center gap-2 rounded-xl border border-slate-300 bg-white px-8 py-3.5 text-base font-medium text-slate-700 shadow-sm transition-all hover:bg-slate-50 hover:shadow-md active:scale-[0.98] dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700"
148
+ className="inline-flex items-center gap-2 rounded-xl border border-slate-300 bg-white px-8 py-3.5 text-base font-medium text-slate-700 shadow-sm transition-[color,background-color,border-color,box-shadow,transform] motion-reduce:transition-none hover:bg-slate-50 hover:shadow-md active:scale-[0.98] dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700"
149
149
  >
150
150
  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="h-5 w-5 text-slate-400">
151
151
  <path d="M3 4a2 2 0 00-2 2v1.161l8.441 4.221a1.25 1.25 0 001.118 0L19 7.162V6a2 2 0 00-2-2H3z" />
@@ -73,10 +73,10 @@ export default function SupportRegisterPage() {
73
73
  }
74
74
 
75
75
  const inputClassName =
76
- 'w-full rounded-xl border border-slate-300 bg-white px-4 py-3 text-sm text-slate-900 outline-none transition-all placeholder:text-slate-400 focus:border-blue-500 focus:ring-4 focus:ring-blue-500/10 dark:border-slate-600 dark:bg-slate-900 dark:text-white dark:placeholder:text-slate-500 dark:focus:border-blue-500 dark:focus:ring-blue-500/20'
76
+ 'w-full rounded-xl border border-slate-300 bg-white px-4 py-3 text-sm text-slate-900 outline-none transition-[color,background-color,border-color,box-shadow,transform] motion-reduce:transition-none placeholder:text-slate-400 focus:border-blue-500 focus:ring-4 focus:ring-blue-500/10 dark:border-slate-600 dark:bg-slate-900 dark:text-white dark:placeholder:text-slate-500 dark:focus:border-blue-500 dark:focus:ring-blue-500/20'
77
77
 
78
78
  return (
79
- <div className="flex min-h-screen flex-col items-center justify-center px-4 py-12 bg-gradient-to-br from-slate-50 via-white to-blue-50 dark:from-slate-950 dark:via-slate-950 dark:to-blue-950/20">
79
+ <div className="flex min-h-[100dvh] flex-col items-center justify-center px-4 py-12 bg-gradient-to-br from-slate-50 via-white to-blue-50 dark:from-slate-950 dark:via-slate-950 dark:to-blue-950/20">
80
80
  {/* Decorative background element */}
81
81
  <div className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_60%_50%_at_50%_0%,rgba(37,99,235,0.08),transparent)] dark:bg-[radial-gradient(ellipse_60%_50%_at_50%_0%,rgba(37,99,235,0.12),transparent)]" />
82
82
 
@@ -242,7 +242,7 @@ export default function SupportRegisterPage() {
242
242
  <button
243
243
  type="submit"
244
244
  disabled={loading}
245
- className="w-full rounded-xl bg-blue-600 px-6 py-3 text-sm font-medium text-white shadow-lg shadow-blue-600/25 transition-all hover:bg-blue-700 hover:shadow-xl active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-50 disabled:shadow-none"
245
+ className="w-full rounded-xl bg-blue-600 px-6 py-3 text-sm font-medium text-white shadow-lg shadow-blue-600/25 transition-[color,background-color,border-color,box-shadow,transform] motion-reduce:transition-none hover:bg-blue-700 hover:shadow-xl active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-50 disabled:shadow-none"
246
246
  >
247
247
  {loading ? 'Creation du compte...' : 'Créer mon compte'}
248
248
  </button>
@@ -257,7 +257,7 @@ export default function SupportRegisterPage() {
257
257
  {/* eslint-disable-next-line @next/next/no-html-link-for-pages */}
258
258
  <a
259
259
  href="/api/support/oauth/google?action=register"
260
- className="flex w-full items-center justify-center gap-3 rounded-xl border border-slate-300 bg-white px-6 py-3 text-sm font-medium text-slate-700 transition-all hover:bg-slate-50 hover:shadow-md active:scale-[0.98] dark:border-slate-600 dark:bg-slate-900 dark:text-slate-300 dark:hover:bg-slate-800"
260
+ className="flex w-full items-center justify-center gap-3 rounded-xl border border-slate-300 bg-white px-6 py-3 text-sm font-medium text-slate-700 transition-[color,background-color,border-color,box-shadow,transform] motion-reduce:transition-none hover:bg-slate-50 hover:shadow-md active:scale-[0.98] dark:border-slate-600 dark:bg-slate-900 dark:text-slate-300 dark:hover:bg-slate-800"
261
261
  >
262
262
  <svg className="h-5 w-5" viewBox="0 0 24 24">
263
263
  <path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z" fill="#4285F4" />
@@ -82,7 +82,7 @@ function ResetPasswordForm() {
82
82
  </div>
83
83
  <Link
84
84
  href="/support/login"
85
- className="block w-full rounded-xl border-3 border-black bg-[#00E5FF] px-6 py-3 text-center text-base font-black text-black shadow-[4px_4px_0px_#000] transition-all hover:translate-x-[2px] hover:translate-y-[2px] hover:shadow-[2px_2px_0px_#000]"
85
+ className="block w-full rounded-xl border-3 border-black bg-[#00E5FF] px-6 py-3 text-center text-base font-black text-black shadow-[4px_4px_0px_#000] transition-[color,background-color,border-color,box-shadow,transform] motion-reduce:transition-none hover:translate-x-[2px] hover:translate-y-[2px] hover:shadow-[2px_2px_0px_#000]"
86
86
  >
87
87
  Se connecter
88
88
  </Link>
@@ -131,7 +131,7 @@ function ResetPasswordForm() {
131
131
  <button
132
132
  type="submit"
133
133
  disabled={loading}
134
- className="w-full rounded-xl border-3 border-black bg-[#00E5FF] px-6 py-3 text-base font-black text-black shadow-[4px_4px_0px_#000] transition-all hover:translate-x-[2px] hover:translate-y-[2px] hover:shadow-[2px_2px_0px_#000] disabled:opacity-50"
134
+ className="w-full rounded-xl border-3 border-black bg-[#00E5FF] px-6 py-3 text-base font-black text-black shadow-[4px_4px_0px_#000] transition-[color,background-color,border-color,box-shadow,transform] motion-reduce:transition-none hover:translate-x-[2px] hover:translate-y-[2px] hover:shadow-[2px_2px_0px_#000] disabled:opacity-50"
135
135
  >
136
136
  {loading ? 'Modification...' : 'Modifier le mot de passe'}
137
137
  </button>
@@ -143,7 +143,7 @@ function ResetPasswordForm() {
143
143
 
144
144
  export default function ResetPasswordPage() {
145
145
  return (
146
- <div className="flex min-h-screen items-center justify-center px-4 py-12">
146
+ <div className="flex min-h-[100dvh] items-center justify-center px-4 py-12">
147
147
  <Suspense fallback={<p className="text-gray-400">Chargement...</p>}>
148
148
  <ResetPasswordForm />
149
149
  </Suspense>
@@ -37,7 +37,7 @@ $font-stack: -apple-system, BlinkMacSystemFont, 'Inter', 'Segoe UI', system-ui,
37
37
  .container {
38
38
  display: flex;
39
39
  gap: 16px;
40
- height: calc(100vh - 220px);
40
+ height: calc(100dvh - 220px);
41
41
  min-height: 500px;
42
42
  }
43
43
 
@@ -531,7 +531,7 @@
531
531
  gap: 8px;
532
532
  padding: 8px 14px;
533
533
  font-size: 12px;
534
- color: #7c3aed;
534
+ color: #17807c;
535
535
  font-weight: 500;
536
536
  }
537
537
 
@@ -539,7 +539,7 @@
539
539
  display: flex;
540
540
  gap: 3px;
541
541
  span {
542
- width: 5px; height: 5px; border-radius: 50%; background: #7c3aed;
542
+ width: 5px; height: 5px; border-radius: 50%; background: #17807c;
543
543
  animation: bounce 1.2s infinite;
544
544
  &:nth-child(2) { animation-delay: 150ms; }
545
545
  &:nth-child(3) { animation-delay: 300ms; }
@@ -612,7 +612,7 @@
612
612
  &:disabled { opacity: 0.4; cursor: not-allowed; }
613
613
  }
614
614
 
615
- // Bouton IA — accent purple, pill style
615
+ // AI buttonteal accent, pill style
616
616
  .toolbarBtnAi {
617
617
  background: var(--theme-accent-50, color-mix(in srgb, var(--theme-accent-500, #6b46e8) 10%, transparent));
618
618
  border-color: color-mix(in srgb, var(--theme-accent-500, #6b46e8) 25%, transparent);
@@ -18,7 +18,7 @@ $green: #16a34a;
18
18
  $amber: #d97706;
19
19
  $orange: #ea580c;
20
20
  $red: #dc2626;
21
- $purple: #7c3aed;
21
+ $teal: #17807c;
22
22
  $cyan: #0891b2;
23
23
 
24
24
  // Mixins
@@ -136,7 +136,7 @@
136
136
  --support-cat-core: #2563eb;
137
137
  --support-cat-communication: #16a34a;
138
138
  --support-cat-productivity: #d97706;
139
- --support-cat-advanced: #7c3aed;
139
+ --support-cat-advanced: #17807c;
140
140
 
141
141
  /* ------------------------------------------
142
142
  * 8. PROJECT STATUS COLORS
@@ -385,7 +385,7 @@
385
385
  --support-activity-line-color: var(--support-border-color);
386
386
  --support-activity-dot-default: var(--support-text-muted);
387
387
  --support-activity-dot-status: var(--support-primary);
388
- --support-activity-dot-assign: #7c3aed;
388
+ --support-activity-dot-assign: #17807c;
389
389
  --support-activity-dot-merge: #0891b2;
390
390
 
391
391
  /* ------------------------------------------
@@ -465,10 +465,10 @@
465
465
  * Indicator for messages scheduled to be
466
466
  * sent at a future date/time.
467
467
  * ------------------------------------------ */
468
- --support-scheduled-bg: #f5f3ff;
468
+ --support-scheduled-bg: #eef8f7;
469
469
  --support-scheduled-border: #a78bfa;
470
470
  --support-scheduled-text: #5b21b6;
471
- --support-scheduled-icon: #7c3aed;
471
+ --support-scheduled-icon: #17807c;
472
472
 
473
473
  /* ------------------------------------------
474
474
  * 34. EMAIL TRACKING
@@ -615,8 +615,8 @@
615
615
 
616
616
  /* Scheduled */
617
617
  --support-scheduled-bg: #2e1065;
618
- --support-scheduled-border: #7c3aed;
619
- --support-scheduled-text: #c4b5fd;
618
+ --support-scheduled-border: #17807c;
619
+ --support-scheduled-text: #80bbb7;
620
620
 
621
621
  /* Email tracking */
622
622
  --support-email-pending: #64748b;
package/src/types.ts CHANGED
@@ -1,3 +1,7 @@
1
+ import type { RateLimitStore } from './utils/rateLimiter'
2
+ import type { InboundEmailInput } from './utils/webhookSecurity'
3
+ import type { Payload, PayloadRequest } from 'payload'
4
+
1
5
  // ─── Feature flags ───────────────────────────────────────
2
6
 
3
7
  export interface SupportFeatures {
@@ -76,6 +80,62 @@ export interface EmailConfig {
76
80
  replyTo?: string
77
81
  }
78
82
 
83
+ export interface SmsSendResult {
84
+ sent: boolean
85
+ skipped?: boolean
86
+ error?: string
87
+ }
88
+
89
+ export interface SmsAdapter {
90
+ isConfigured?: () => boolean
91
+ send: (input: {
92
+ message: string
93
+ to: string
94
+ payload: Payload
95
+ ticket: Record<string, unknown>
96
+ client: Record<string, unknown>
97
+ }) => Promise<SmsSendResult>
98
+ }
99
+
100
+ export interface SupportCapabilities {
101
+ /** Optional SMS transport. The module never depends on a concrete provider. */
102
+ sms?: {
103
+ adapter: SmsAdapter
104
+ buildMessage?: (input: {
105
+ client: Record<string, unknown>
106
+ ticket: Record<string, unknown>
107
+ }) => string | Promise<string>
108
+ }
109
+ /** Native daily/weekly notification queue. */
110
+ digests?: boolean
111
+ /** Incoming email business adapter, protected and validated by the module. */
112
+ inboundEmail?: {
113
+ handle: (req: PayloadRequest, input: InboundEmailInput) => Promise<Response>
114
+ secret?: string
115
+ secretHeader?: string
116
+ }
117
+ /** Generate a short, editable display title after ticket creation. */
118
+ aiTitles?: {
119
+ generate: (payload: Payload, ticketId: number | string) => Promise<unknown>
120
+ }
121
+ /** Override the module's default resolved-ticket synthesis generator. */
122
+ aiSummaries?: {
123
+ generate: (payload: Payload, ticketId: number | string) => Promise<unknown>
124
+ }
125
+ /** Persist billing lines and their invoicing state. */
126
+ detailedBilling?: boolean
127
+ /** Persist pro-bono work and its estimated value. */
128
+ volunteering?: boolean
129
+ /** Best-effort cleanup of noisy messages after an inbound client reply. */
130
+ threadCleanup?: {
131
+ clean: (payload: Payload, ticketId: number | string, messageId: number | string) => Promise<unknown>
132
+ }
133
+ /** Deployment-specific project suggestion adapter. */
134
+ projectSuggestions?: {
135
+ suggest: (req: PayloadRequest) => Promise<Response>
136
+ }
137
+ }
138
+
79
139
  // ─── Plugin configuration ────────────────────────────────
80
140
 
81
141
  export interface SupportPluginConfig {
@@ -88,6 +148,15 @@ export interface SupportPluginConfig {
88
148
  /** Email configuration for ticket notifications */
89
149
  email?: EmailConfig
90
150
 
151
+ /** Shared rate-limit storage. Defaults to process-local memory. */
152
+ rateLimitStore?: RateLimitStore | 'payload'
153
+
154
+ /** Sequential ticket number formatting. */
155
+ ticketNumber?: { prefix?: string; padding?: number }
156
+
157
+ /** Optional generic capabilities backed by deployment-specific adapters. */
158
+ capabilities?: SupportCapabilities
159
+
91
160
  /** Locale: 'fr' or 'en' (default: 'fr') */
92
161
  locale?: 'fr' | 'en'
93
162
 
@@ -136,6 +205,8 @@ export interface SupportPluginConfig {
136
205
  macros?: string
137
206
  ticketStatuses?: string
138
207
  ticketFeedback?: string
208
+ rateLimits?: string
209
+ counters?: string
139
210
  }
140
211
 
141
212
  /** Admin notification collection slug (default: 'admin-notifications') */
@@ -1,52 +1,145 @@
1
+ export interface RateLimitEntry {
2
+ count: number
3
+ resetAt: number
4
+ }
5
+
6
+ export interface RateLimitStore {
7
+ increment(key: string, windowMs: number, context?: unknown): Promise<RateLimitEntry>
8
+ reset(key: string, context?: unknown): Promise<void>
9
+ }
10
+
1
11
  /**
2
- * Shared in-memory rate limiter with automatic cleanup.
3
- * Replaces duplicated rate-limiting logic across endpoints.
12
+ * Process-local fallback store. Applications running more than one process
13
+ * should provide a persistent RateLimitStore through the plugin options.
4
14
  */
5
- export class RateLimiter {
6
- private store = new Map<string, { count: number; resetAt: number }>()
15
+ export class MemoryRateLimitStore implements RateLimitStore {
16
+ private readonly entries = new Map<string, RateLimitEntry>()
7
17
 
8
- constructor(
9
- private windowMs: number,
10
- private maxRequests: number,
11
- ) {
12
- // Periodic cleanup to prevent memory leaks
13
- const timer = setInterval(() => this.cleanup(), windowMs)
14
- timer.unref()
18
+ async increment(key: string, windowMs: number): Promise<RateLimitEntry> {
19
+ const now = Date.now()
20
+ const current = this.entries.get(key)
21
+ const next = !current || now > current.resetAt
22
+ ? { count: 1, resetAt: now + windowMs }
23
+ : { ...current, count: current.count + 1 }
24
+
25
+ this.entries.set(key, next)
26
+ return next
15
27
  }
16
28
 
17
- /**
18
- * Check if a key has exceeded the rate limit.
19
- * Returns true if the request should be blocked.
20
- */
21
- check(key: string): boolean {
22
- const now = Date.now()
23
- const entry = this.store.get(key)
29
+ async reset(key: string): Promise<void> {
30
+ this.entries.delete(key)
31
+ }
32
+ }
24
33
 
25
- if (!entry || now > entry.resetAt) {
26
- this.store.set(key, { count: 1, resetAt: now + this.windowMs })
27
- return false
28
- }
34
+ interface PayloadRateLimitContext {
35
+ find(args: Record<string, unknown>): Promise<{ docs: Array<Record<string, unknown>> }>
36
+ create(args: Record<string, unknown>): Promise<Record<string, unknown>>
37
+ update(args: Record<string, unknown>): Promise<Record<string, unknown>>
38
+ delete(args: Record<string, unknown>): Promise<unknown>
39
+ }
29
40
 
30
- entry.count++
31
- return entry.count > this.maxRequests
41
+ type PayloadRateLimitRequest = Pick<PayloadRequest, 'payload' | 'transactionID'>
42
+
43
+ export class PayloadRateLimitStore implements RateLimitStore {
44
+ constructor(private readonly collectionSlug = 'support-rate-limits') {}
45
+
46
+ async increment(key: string, windowMs: number, context?: unknown): Promise<RateLimitEntry> {
47
+ const { payload, req } = this.resolveContext(context)
48
+ const ownsTransaction = req ? await initTransaction(req) : false
49
+ try {
50
+ const now = Date.now()
51
+ const result = await payload.find({
52
+ collection: this.collectionSlug,
53
+ where: { key: { equals: key } },
54
+ limit: 1,
55
+ depth: 0,
56
+ overrideAccess: true,
57
+ ...(req ? { req } : {}),
58
+ })
59
+ const current = result.docs[0]
60
+ const currentResetAt = current?.resetAt ? new Date(String(current.resetAt)).getTime() : 0
61
+ const next = !current || now > currentResetAt
62
+ ? { count: 1, resetAt: now + windowMs }
63
+ : { count: Number(current.count || 0) + 1, resetAt: currentResetAt }
64
+
65
+ if (current?.id != null) {
66
+ await payload.update({
67
+ collection: this.collectionSlug,
68
+ id: current.id,
69
+ data: { count: next.count, resetAt: new Date(next.resetAt).toISOString() },
70
+ overrideAccess: true,
71
+ ...(req ? { req } : {}),
72
+ })
73
+ } else {
74
+ await payload.create({
75
+ collection: this.collectionSlug,
76
+ data: { key, count: next.count, resetAt: new Date(next.resetAt).toISOString() },
77
+ overrideAccess: true,
78
+ ...(req ? { req } : {}),
79
+ })
80
+ }
81
+
82
+ if (ownsTransaction && req) await commitTransaction(req)
83
+ return next
84
+ } catch (error) {
85
+ if (ownsTransaction && req) await killTransaction(req)
86
+ throw error
87
+ }
32
88
  }
33
89
 
34
- /**
35
- * Reset the counter for a specific key.
36
- */
37
- reset(key: string): void {
38
- this.store.delete(key)
90
+ async reset(key: string, context?: unknown): Promise<void> {
91
+ const { payload, req } = this.resolveContext(context)
92
+ const ownsTransaction = req ? await initTransaction(req) : false
93
+ try {
94
+ await payload.delete({
95
+ collection: this.collectionSlug,
96
+ where: { key: { equals: key } },
97
+ overrideAccess: true,
98
+ ...(req ? { req } : {}),
99
+ })
100
+ if (ownsTransaction && req) await commitTransaction(req)
101
+ } catch (error) {
102
+ if (ownsTransaction && req) await killTransaction(req)
103
+ throw error
104
+ }
39
105
  }
40
106
 
41
- /**
42
- * Remove expired entries from the store.
43
- */
44
- private cleanup(): void {
45
- const now = Date.now()
46
- for (const [key, entry] of this.store) {
47
- if (now > entry.resetAt) {
48
- this.store.delete(key)
49
- }
107
+ private resolveContext(context: unknown): {
108
+ payload: PayloadRateLimitContext
109
+ req?: PayloadRateLimitRequest
110
+ } {
111
+ if (!context || typeof context !== 'object') {
112
+ throw new Error('PayloadRateLimitStore requires the current Payload request or instance')
50
113
  }
114
+ if ('payload' in context) {
115
+ const req = context as PayloadRateLimitRequest
116
+ return { payload: req.payload as unknown as PayloadRateLimitContext, req }
117
+ }
118
+ return { payload: context as PayloadRateLimitContext }
119
+ }
120
+ }
121
+
122
+ export class RateLimiter {
123
+ private readonly store: RateLimitStore
124
+
125
+ constructor(
126
+ private readonly windowMs: number,
127
+ private readonly maxRequests: number,
128
+ store?: RateLimitStore,
129
+ ) {
130
+ this.store = store ?? new MemoryRateLimitStore()
131
+ }
132
+
133
+ async check(key: string, context?: unknown): Promise<boolean> {
134
+ const entry = context === undefined
135
+ ? await this.store.increment(key, this.windowMs)
136
+ : await this.store.increment(key, this.windowMs, context)
137
+ return entry.count > this.maxRequests
138
+ }
139
+
140
+ async reset(key: string, context?: unknown): Promise<void> {
141
+ if (context === undefined) await this.store.reset(key)
142
+ else await this.store.reset(key, context)
51
143
  }
52
144
  }
145
+ import { commitTransaction, initTransaction, killTransaction, type PayloadRequest } from 'payload'
@@ -24,6 +24,8 @@ export interface CollectionSlugs {
24
24
  automationRules: string
25
25
  supportTeams: string
26
26
  pushSubscriptions: string
27
+ rateLimits: string
28
+ counters: string
27
29
  users: string
28
30
  media: string
29
31
  }
@@ -50,6 +52,8 @@ export const DEFAULT_SLUGS: CollectionSlugs = {
50
52
  automationRules: 'automation-rules',
51
53
  supportTeams: 'support-teams',
52
54
  pushSubscriptions: 'push-subscriptions',
55
+ rateLimits: 'support-rate-limits',
56
+ counters: 'support-counters',
53
57
  users: 'users',
54
58
  media: 'media',
55
59
  }