@consilioweb/payload-support 1.1.1 → 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.
- package/README.md +4 -0
- package/dist/components/TicketConversation/components/AISummaryPanel.cjs +22 -14
- package/dist/components/TicketConversation/components/AISummaryPanel.js +23 -16
- package/dist/components/TicketConversation/components/ActionPanels.cjs +5 -5
- package/dist/components/TicketConversation/components/ActionPanels.js +5 -5
- package/dist/components/TicketConversation/components/ClientBar.cjs +1 -1
- package/dist/components/TicketConversation/components/ClientBar.js +1 -1
- package/dist/components/TicketConversation/components/QuickActions.cjs +2 -2
- package/dist/components/TicketConversation/components/QuickActions.js +2 -2
- package/dist/components/TicketConversation/components/TicketHeader.cjs +1 -1
- package/dist/components/TicketConversation/components/TicketHeader.js +1 -1
- package/dist/components/TicketConversation/index.cjs +10 -10
- package/dist/components/TicketConversation/index.js +10 -10
- package/dist/index.cjs +709 -173
- package/dist/index.d.cts +165 -3
- package/dist/index.d.ts +165 -3
- package/dist/index.js +702 -175
- package/dist/styles/ChatView.module.scss +1 -1
- package/dist/styles/TicketDetail.module.scss +3 -3
- package/dist/styles/_tokens.scss +1 -1
- package/dist/styles/theme.css +6 -6
- package/dist/views/TicketingSettingsView/client.cjs +3 -3
- package/dist/views/TicketingSettingsView/client.js +3 -3
- package/package.json +9 -9
- package/src/__tests__/aiSummaryRendering.test.ts +21 -0
- package/src/__tests__/authResponses.test.ts +69 -0
- package/src/__tests__/capabilities.test.ts +71 -0
- package/src/__tests__/generateTrackingToken.test.ts +18 -4
- package/src/__tests__/integration/core-behaviors.test.ts +23 -0
- package/src/__tests__/rateLimiter.test.ts +60 -66
- package/src/__tests__/trackOpenEndpoint.test.ts +54 -0
- package/src/__tests__/webhookSecurity.test.ts +58 -0
- package/src/collections/PendingEmails.ts +2 -1
- package/src/collections/SupportClients.ts +43 -1
- package/src/collections/SupportCounters.ts +14 -0
- package/src/collections/SupportRateLimits.ts +20 -0
- package/src/collections/TicketMessages.ts +76 -0
- package/src/collections/Tickets.ts +179 -39
- package/src/collections/index.ts +2 -0
- package/src/components/TicketConversation/components/AISummaryPanel.tsx +26 -13
- package/src/components/TicketConversation/components/ActionPanels.tsx +5 -5
- package/src/components/TicketConversation/components/ClientBar.tsx +1 -1
- package/src/components/TicketConversation/components/QuickActions.tsx +2 -2
- package/src/components/TicketConversation/components/TicketHeader.tsx +1 -1
- package/src/components/TicketConversation/index.tsx +10 -10
- package/src/endpoints/admin-chat.ts +4 -4
- package/src/endpoints/ai-agent.ts +6 -1
- package/src/endpoints/ai.ts +10 -5
- package/src/endpoints/auth-2fa.ts +6 -7
- package/src/endpoints/auto-close.ts +2 -1
- package/src/endpoints/capabilities.ts +144 -0
- package/src/endpoints/channels.ts +2 -1
- package/src/endpoints/chat.ts +6 -6
- package/src/endpoints/chatbot.ts +4 -4
- package/src/endpoints/client-intelligence.ts +13 -4
- package/src/endpoints/import-conversation.ts +6 -5
- package/src/endpoints/index.ts +35 -14
- package/src/endpoints/invite-collaborator.ts +4 -4
- package/src/endpoints/login.ts +4 -5
- package/src/endpoints/oauth-google.ts +1 -1
- package/src/endpoints/process-digests.ts +2 -1
- package/src/endpoints/process-scheduled.ts +2 -1
- package/src/endpoints/process-snooze.ts +2 -1
- package/src/endpoints/resend-notification.ts +4 -4
- package/src/endpoints/send-reminder.ts +4 -4
- package/src/endpoints/ticket-synthesis.ts +17 -2
- package/src/endpoints/track-open.ts +44 -21
- package/src/endpoints/transfer-ticket.ts +4 -4
- package/src/index.ts +7 -0
- package/src/plugin.ts +16 -1
- package/src/portal/LiveChat.tsx +1 -1
- package/src/portal/auth/ChatWidget.tsx +4 -4
- package/src/portal/auth/ChatbotWidget.tsx +4 -4
- package/src/portal/auth/dashboard/DashboardClient.tsx +2 -2
- package/src/portal/auth/faq/FAQSearch.tsx +1 -1
- package/src/portal/auth/faq/page.tsx +2 -2
- package/src/portal/auth/profile/page.tsx +17 -17
- package/src/portal/auth/tickets/detail/CloseTicketButton.tsx +1 -1
- package/src/portal/auth/tickets/detail/MarkSolutionButton.tsx +1 -1
- package/src/portal/auth/tickets/detail/ReopenTicketButton.tsx +1 -1
- package/src/portal/auth/tickets/detail/SatisfactionForm.tsx +3 -3
- package/src/portal/auth/tickets/detail/TicketReplyForm.tsx +3 -3
- package/src/portal/auth/tickets/detail/page.tsx +5 -5
- package/src/portal/auth/tickets/new/page.tsx +8 -8
- package/src/portal/forgot-password/page.tsx +3 -3
- package/src/portal/layout.tsx +5 -2
- package/src/portal/login/page.tsx +7 -7
- package/src/portal/page.tsx +9 -9
- package/src/portal/register/page.tsx +4 -4
- package/src/portal/reset-password/page.tsx +3 -3
- package/src/styles/ChatView.module.scss +1 -1
- package/src/styles/TicketDetail.module.scss +3 -3
- package/src/styles/_tokens.scss +1 -1
- package/src/styles/theme.css +6 -6
- package/src/types.ts +71 -0
- package/src/utils/rateLimiter.ts +131 -38
- package/src/utils/slugs.ts +4 -0
- package/src/utils/webhookSecurity.ts +81 -0
- package/src/views/TicketingSettingsView/client.tsx +3 -3
package/src/endpoints/index.ts
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
import type { Endpoint } from 'payload'
|
|
2
2
|
import type { CollectionSlugs } from '../utils/slugs'
|
|
3
3
|
import type { SupportFeatures } from '../types'
|
|
4
|
+
import type { RateLimitStore } from '../utils/rateLimiter'
|
|
5
|
+
import type { SupportCapabilities } from '../types'
|
|
6
|
+
import {
|
|
7
|
+
createGenerateMissingTitlesEndpoint,
|
|
8
|
+
createInboundEmailEndpoint,
|
|
9
|
+
createProjectSuggestionsEndpoint,
|
|
10
|
+
createTicketTitleEndpoint,
|
|
11
|
+
} from './capabilities'
|
|
4
12
|
|
|
5
13
|
import { createAiEndpoint } from './ai'
|
|
6
14
|
import { createAiAgentEndpoint } from './ai-agent'
|
|
@@ -112,6 +120,8 @@ export { createInviteCollaboratorEndpoint } from './invite-collaborator'
|
|
|
112
120
|
export interface SupportEndpointOptions {
|
|
113
121
|
oauth?: OAuthGoogleOptions
|
|
114
122
|
features?: Required<SupportFeatures>
|
|
123
|
+
rateLimitStore?: RateLimitStore
|
|
124
|
+
capabilities?: SupportCapabilities
|
|
115
125
|
}
|
|
116
126
|
|
|
117
127
|
/**
|
|
@@ -121,6 +131,7 @@ export interface SupportEndpointOptions {
|
|
|
121
131
|
*/
|
|
122
132
|
export function createSupportEndpoints(slugs: CollectionSlugs, options?: SupportEndpointOptions): Endpoint[] {
|
|
123
133
|
const f = options?.features
|
|
134
|
+
const rateLimitStore = options?.rateLimitStore
|
|
124
135
|
|
|
125
136
|
// Core endpoints (always present)
|
|
126
137
|
const endpoints: Endpoint[] = [
|
|
@@ -132,28 +143,28 @@ export function createSupportEndpoints(slugs: CollectionSlugs, options?: Support
|
|
|
132
143
|
createExportCsvEndpoint(slugs),
|
|
133
144
|
createExportDataEndpoint(slugs),
|
|
134
145
|
createSeedKbEndpoint(slugs),
|
|
135
|
-
createLoginEndpoint(slugs),
|
|
136
|
-
createAuth2faEndpoint(slugs),
|
|
146
|
+
createLoginEndpoint(slugs, rateLimitStore),
|
|
147
|
+
createAuth2faEndpoint(slugs, rateLimitStore),
|
|
137
148
|
createOAuthGoogleEndpoint(slugs, options?.oauth),
|
|
138
149
|
createDeleteAccountEndpoint(slugs),
|
|
139
150
|
createMergeClientsEndpoint(slugs),
|
|
140
|
-
createImportConversationEndpoint(slugs),
|
|
151
|
+
createImportConversationEndpoint(slugs, rateLimitStore),
|
|
141
152
|
createPurgeLogsEndpoint(slugs),
|
|
142
|
-
createResendNotificationEndpoint(slugs),
|
|
153
|
+
createResendNotificationEndpoint(slugs, rateLimitStore),
|
|
143
154
|
createUserPrefsGetEndpoint(slugs),
|
|
144
155
|
createUserPrefsPostEndpoint(slugs),
|
|
145
156
|
createEscalateEndpoint(slugs),
|
|
146
157
|
createTicketFeedbackEndpoint(slugs),
|
|
147
|
-
createTransferTicketEndpoint(slugs),
|
|
148
|
-
createInviteCollaboratorEndpoint(slugs),
|
|
158
|
+
createTransferTicketEndpoint(slugs, rateLimitStore),
|
|
159
|
+
createInviteCollaboratorEndpoint(slugs, rateLimitStore),
|
|
149
160
|
]
|
|
150
161
|
|
|
151
162
|
// Conditional endpoints based on feature flags
|
|
152
163
|
if (!f || f.ai !== false) {
|
|
153
|
-
endpoints.push(createAiEndpoint(slugs))
|
|
154
|
-
endpoints.push(createAiAgentEndpoint(slugs))
|
|
155
|
-
endpoints.push(...createClientIntelligenceEndpoint(slugs))
|
|
156
|
-
endpoints.push(createTicketSynthesisEndpoint(slugs))
|
|
164
|
+
endpoints.push(createAiEndpoint(slugs, rateLimitStore))
|
|
165
|
+
endpoints.push(createAiAgentEndpoint(slugs, rateLimitStore))
|
|
166
|
+
endpoints.push(...createClientIntelligenceEndpoint(slugs, rateLimitStore))
|
|
167
|
+
endpoints.push(createTicketSynthesisEndpoint(slugs, options?.capabilities?.aiSummaries, rateLimitStore))
|
|
157
168
|
}
|
|
158
169
|
if (!f || f.bulkActions !== false) endpoints.push(createBulkActionEndpoint(slugs))
|
|
159
170
|
if (!f || f.merge !== false) endpoints.push(createMergeTicketsEndpoint(slugs))
|
|
@@ -168,18 +179,18 @@ export function createSupportEndpoints(slugs: CollectionSlugs, options?: Support
|
|
|
168
179
|
if (!f || f.sla !== false) endpoints.push(createSlaCheckEndpoint(slugs))
|
|
169
180
|
if (!f || f.autoClose !== false) {
|
|
170
181
|
endpoints.push(createAutoCloseEndpoint(slugs))
|
|
171
|
-
endpoints.push(createSendReminderEndpoint(slugs))
|
|
182
|
+
endpoints.push(createSendReminderEndpoint(slugs, rateLimitStore))
|
|
172
183
|
}
|
|
173
184
|
if (!f || f.customStatuses !== false) endpoints.push(createStatusesEndpoint(slugs))
|
|
174
185
|
if (!f || f.macros !== false) endpoints.push(createApplyMacroEndpoint(slugs))
|
|
175
186
|
if (!f || f.roundRobin !== false) {
|
|
176
187
|
endpoints.push(createRoundRobinConfigGetEndpoint(slugs), createRoundRobinConfigPostEndpoint(slugs))
|
|
177
188
|
}
|
|
178
|
-
if (!f || f.chatbot !== false) endpoints.push(createChatbotEndpoint(slugs))
|
|
189
|
+
if (!f || f.chatbot !== false) endpoints.push(createChatbotEndpoint(slugs, rateLimitStore))
|
|
179
190
|
if (!f || f.chat !== false) {
|
|
180
|
-
endpoints.push(createChatGetEndpoint(slugs), createChatPostEndpoint(slugs))
|
|
191
|
+
endpoints.push(createChatGetEndpoint(slugs), createChatPostEndpoint(slugs, rateLimitStore))
|
|
181
192
|
endpoints.push(createChatStreamEndpoint(slugs))
|
|
182
|
-
endpoints.push(createAdminChatGetEndpoint(slugs), createAdminChatPostEndpoint(slugs))
|
|
193
|
+
endpoints.push(createAdminChatGetEndpoint(slugs), createAdminChatPostEndpoint(slugs, rateLimitStore))
|
|
183
194
|
endpoints.push(createAdminChatStreamEndpoint(slugs))
|
|
184
195
|
}
|
|
185
196
|
if (!f || f.timeTracking !== false) {
|
|
@@ -197,6 +208,16 @@ export function createSupportEndpoints(slugs: CollectionSlugs, options?: Support
|
|
|
197
208
|
endpoints.push(createChannelsWebhookEndpoint(slugs))
|
|
198
209
|
endpoints.push(createVapidKeyEndpoint())
|
|
199
210
|
endpoints.push(createPushSubscribeEndpoint(slugs))
|
|
211
|
+
if (options?.capabilities?.inboundEmail) {
|
|
212
|
+
endpoints.push(createInboundEmailEndpoint(options.capabilities.inboundEmail, rateLimitStore))
|
|
213
|
+
}
|
|
214
|
+
if (options?.capabilities?.projectSuggestions) {
|
|
215
|
+
endpoints.push(createProjectSuggestionsEndpoint(slugs, options.capabilities.projectSuggestions, rateLimitStore))
|
|
216
|
+
}
|
|
217
|
+
if (options?.capabilities?.aiTitles) {
|
|
218
|
+
endpoints.push(createTicketTitleEndpoint(slugs, options.capabilities.aiTitles, rateLimitStore))
|
|
219
|
+
endpoints.push(createGenerateMissingTitlesEndpoint(slugs, options.capabilities.aiTitles, rateLimitStore))
|
|
220
|
+
}
|
|
200
221
|
|
|
201
222
|
return endpoints
|
|
202
223
|
}
|
|
@@ -4,7 +4,7 @@ import { handleAuthError, AuthError } from '../utils/auth'
|
|
|
4
4
|
import { escapeHtml, emailWrapper, emailButton, emailParagraph } from '../utils/emailTemplate'
|
|
5
5
|
import { readSupportSettings } from '../utils/readSettings'
|
|
6
6
|
import { randomBytes } from 'crypto'
|
|
7
|
-
import { RateLimiter } from '../utils/rateLimiter'
|
|
7
|
+
import { RateLimiter, type RateLimitStore } from '../utils/rateLimiter'
|
|
8
8
|
import { dbFindByID, dbFind, dbCreate, dbUpdate, dbCount } from '../utils/db'
|
|
9
9
|
|
|
10
10
|
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
|
@@ -12,7 +12,6 @@ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
|
|
12
12
|
// Anti-abuse limits: an invite can create a placeholder account AND send an
|
|
13
13
|
// email, so cap both the volume per user and the collaborators per ticket.
|
|
14
14
|
const MAX_COLLABORATORS_PER_TICKET = 20
|
|
15
|
-
const inviteLimiter = new RateLimiter(60 * 60 * 1000, 10)
|
|
16
15
|
|
|
17
16
|
/**
|
|
18
17
|
* POST /api/support/tickets/:id/invite
|
|
@@ -31,7 +30,8 @@ const inviteLimiter = new RateLimiter(60 * 60 * 1000, 10)
|
|
|
31
30
|
* - Sends an invitation email with a magic link `/support/tickets/:id?inviteToken=...`.
|
|
32
31
|
* - Returns { ok, invitedTo, role }.
|
|
33
32
|
*/
|
|
34
|
-
export function createInviteCollaboratorEndpoint(slugs: CollectionSlugs): Endpoint {
|
|
33
|
+
export function createInviteCollaboratorEndpoint(slugs: CollectionSlugs, store?: RateLimitStore): Endpoint {
|
|
34
|
+
const inviteLimiter = new RateLimiter(60 * 60 * 1000, 10, store)
|
|
35
35
|
return {
|
|
36
36
|
path: '/support/tickets/:id/invite',
|
|
37
37
|
method: 'post',
|
|
@@ -75,7 +75,7 @@ export function createInviteCollaboratorEndpoint(slugs: CollectionSlugs): Endpoi
|
|
|
75
75
|
}
|
|
76
76
|
|
|
77
77
|
// Anti-abuse: cap invite volume per user (in-memory, fail-closed).
|
|
78
|
-
if (inviteLimiter.check(String(req.user.id))) {
|
|
78
|
+
if (await inviteLimiter.check(String(req.user.id), req)) {
|
|
79
79
|
return Response.json({ error: 'Trop d\'invitations. Réessayez plus tard.' }, { status: 429 })
|
|
80
80
|
}
|
|
81
81
|
|
package/src/endpoints/login.ts
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
import type { Endpoint } from 'payload'
|
|
2
2
|
import type { CollectionSlugs } from '../utils/slugs'
|
|
3
|
-
import { RateLimiter } from '../utils/rateLimiter'
|
|
3
|
+
import { RateLimiter, type RateLimitStore } from '../utils/rateLimiter'
|
|
4
4
|
import { dbCreate } from '../utils/db'
|
|
5
5
|
|
|
6
|
-
const loginLimiter = new RateLimiter(15 * 60_000, 10) // 10 per 15 min
|
|
7
6
|
|
|
8
7
|
/**
|
|
9
8
|
* POST /api/support/login
|
|
10
9
|
* Client login endpoint.
|
|
11
10
|
*/
|
|
12
|
-
export function createLoginEndpoint(slugs: CollectionSlugs): Endpoint {
|
|
11
|
+
export function createLoginEndpoint(slugs: CollectionSlugs, store?: RateLimitStore): Endpoint {
|
|
12
|
+
const loginLimiter = new RateLimiter(15 * 60_000, 10, store)
|
|
13
13
|
return {
|
|
14
14
|
path: '/support/login',
|
|
15
15
|
method: 'post',
|
|
@@ -19,7 +19,7 @@ export function createLoginEndpoint(slugs: CollectionSlugs): Endpoint {
|
|
|
19
19
|
// the primary brute-force control is Payload's account lock (maxLoginAttempts).
|
|
20
20
|
const ip = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || req.headers.get('x-real-ip') || 'unknown'
|
|
21
21
|
|
|
22
|
-
if (loginLimiter.check(ip)) {
|
|
22
|
+
if (await loginLimiter.check(ip, req)) {
|
|
23
23
|
return Response.json(
|
|
24
24
|
{ error: 'Trop de tentatives. Réessayez dans quelques minutes.' },
|
|
25
25
|
{ status: 429 },
|
|
@@ -66,7 +66,6 @@ export function createLoginEndpoint(slugs: CollectionSlugs): Endpoint {
|
|
|
66
66
|
JSON.stringify({
|
|
67
67
|
message: 'Login successful',
|
|
68
68
|
user: result.user,
|
|
69
|
-
token: result.token,
|
|
70
69
|
exp: result.exp,
|
|
71
70
|
}),
|
|
72
71
|
{ status: 200, headers },
|
|
@@ -210,7 +210,7 @@ export function createOAuthGoogleEndpoint(slugs: CollectionSlugs, options?: OAut
|
|
|
210
210
|
`payload-token=${token}; HttpOnly; ${cookieSecure ? 'Secure; ' : ''}SameSite=Lax; Path=/; Max-Age=${tokenExpiration}`,
|
|
211
211
|
)
|
|
212
212
|
|
|
213
|
-
return new Response(JSON.stringify({
|
|
213
|
+
return new Response(JSON.stringify({ user: clientDoc, exp }), {
|
|
214
214
|
status: 200,
|
|
215
215
|
headers,
|
|
216
216
|
})
|
|
@@ -3,6 +3,7 @@ import type { CollectionSlugs } from '../utils/slugs'
|
|
|
3
3
|
import { dbFind, dbFindByID, dbDelete } from '../utils/db'
|
|
4
4
|
import { emailWrapper, emailParagraph, escapeHtml } from '../utils/emailTemplate'
|
|
5
5
|
import { readSupportSettings } from '../utils/readSettings'
|
|
6
|
+
import { verifySecret } from '../utils/webhookSecurity'
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* POST /api/support/process-digests body: { frequency?: 'daily' | 'weekly' }
|
|
@@ -18,7 +19,7 @@ export function createProcessDigestsEndpoint(slugs: CollectionSlugs): Endpoint {
|
|
|
18
19
|
method: 'post',
|
|
19
20
|
handler: async (req) => {
|
|
20
21
|
const secret = req.headers.get('x-cron-secret')
|
|
21
|
-
if (!
|
|
22
|
+
if (!verifySecret(secret, process.env.CRON_SECRET)) {
|
|
22
23
|
return Response.json({ error: 'Non autorisé' }, { status: 401 })
|
|
23
24
|
}
|
|
24
25
|
|
|
@@ -4,6 +4,7 @@ import { escapeHtml } from '../utils/emailTemplate'
|
|
|
4
4
|
import { fireWebhooks } from '../utils/fireWebhooks'
|
|
5
5
|
import { readSupportSettings } from '../utils/readSettings'
|
|
6
6
|
import { dbFind, dbFindByID, dbUpdate } from '../utils/db'
|
|
7
|
+
import { verifySecret } from '../utils/webhookSecurity'
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* POST /api/support/process-scheduled
|
|
@@ -18,7 +19,7 @@ export function createProcessScheduledEndpoint(slugs: CollectionSlugs): Endpoint
|
|
|
18
19
|
const secret = req.headers.get('x-cron-secret')
|
|
19
20
|
const expectedSecret = process.env.CRON_SECRET
|
|
20
21
|
|
|
21
|
-
if (!
|
|
22
|
+
if (!verifySecret(secret, expectedSecret)) {
|
|
22
23
|
return Response.json({ error: 'Non autorisé' }, { status: 401 })
|
|
23
24
|
}
|
|
24
25
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Endpoint } from 'payload'
|
|
2
2
|
import type { CollectionSlugs } from '../utils/slugs'
|
|
3
3
|
import { dbFind, dbUpdate, dbCreate } from '../utils/db'
|
|
4
|
+
import { verifySecret } from '../utils/webhookSecurity'
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* POST /api/support/process-snooze
|
|
@@ -20,7 +21,7 @@ export function createProcessSnoozeEndpoint(slugs: CollectionSlugs): Endpoint {
|
|
|
20
21
|
handler: async (req) => {
|
|
21
22
|
const secret = req.headers.get('x-cron-secret')
|
|
22
23
|
const expectedSecret = process.env.CRON_SECRET
|
|
23
|
-
if (!
|
|
24
|
+
if (!verifySecret(secret, expectedSecret)) {
|
|
24
25
|
return Response.json({ error: 'Non autorisé' }, { status: 401 })
|
|
25
26
|
}
|
|
26
27
|
|
|
@@ -1,18 +1,18 @@
|
|
|
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 } from '../utils/rateLimiter'
|
|
4
|
+
import { 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'
|
|
8
8
|
|
|
9
|
-
const resendLimiter = new RateLimiter(60 * 60 * 1000, 10) // 10 per hour
|
|
10
9
|
|
|
11
10
|
/**
|
|
12
11
|
* POST /api/support/resend-notification
|
|
13
12
|
* Resend the email notification for a specific ticket message. Admin-only.
|
|
14
13
|
*/
|
|
15
|
-
export function createResendNotificationEndpoint(slugs: CollectionSlugs): Endpoint {
|
|
14
|
+
export function createResendNotificationEndpoint(slugs: CollectionSlugs, store?: RateLimitStore): Endpoint {
|
|
15
|
+
const resendLimiter = new RateLimiter(60 * 60 * 1000, 10, store)
|
|
16
16
|
return {
|
|
17
17
|
path: '/support/resend-notification',
|
|
18
18
|
method: 'post',
|
|
@@ -22,7 +22,7 @@ export function createResendNotificationEndpoint(slugs: CollectionSlugs): Endpoi
|
|
|
22
22
|
|
|
23
23
|
requireAdmin(req, slugs)
|
|
24
24
|
|
|
25
|
-
if (resendLimiter.check(String(req.user.id))) {
|
|
25
|
+
if (await resendLimiter.check(String(req.user.id), req)) {
|
|
26
26
|
return Response.json(
|
|
27
27
|
{ error: 'Trop de renvois. Réessayez dans une heure.' },
|
|
28
28
|
{ status: 429 },
|
|
@@ -1,14 +1,13 @@
|
|
|
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 } from '../utils/rateLimiter'
|
|
4
|
+
import { 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'
|
|
8
8
|
|
|
9
9
|
// Manual reminders are a deliberate admin action, so the cap is generous —
|
|
10
10
|
// it only exists to stop a runaway script, not normal usage.
|
|
11
|
-
const reminderLimiter = new RateLimiter(60 * 60 * 1000, 30) // 30 per hour per admin
|
|
12
11
|
|
|
13
12
|
const MIN_HOURS = 1
|
|
14
13
|
const MAX_HOURS = 24 * 30 // 30 days
|
|
@@ -38,7 +37,8 @@ function formatFr(date: Date, withTime: boolean): string {
|
|
|
38
37
|
*
|
|
39
38
|
* Admin-only, rate-limited.
|
|
40
39
|
*/
|
|
41
|
-
export function createSendReminderEndpoint(slugs: CollectionSlugs): Endpoint {
|
|
40
|
+
export function createSendReminderEndpoint(slugs: CollectionSlugs, store?: RateLimitStore): Endpoint {
|
|
41
|
+
const reminderLimiter = new RateLimiter(60 * 60 * 1000, 30, store)
|
|
42
42
|
return {
|
|
43
43
|
path: '/support/send-reminder',
|
|
44
44
|
method: 'post',
|
|
@@ -48,7 +48,7 @@ export function createSendReminderEndpoint(slugs: CollectionSlugs): Endpoint {
|
|
|
48
48
|
|
|
49
49
|
requireAdmin(req, slugs)
|
|
50
50
|
|
|
51
|
-
if (reminderLimiter.check(String(req.user.id))) {
|
|
51
|
+
if (await reminderLimiter.check(String(req.user.id), req)) {
|
|
52
52
|
return Response.json(
|
|
53
53
|
{ error: 'Trop de relances. Réessayez dans une heure.' },
|
|
54
54
|
{ status: 429 },
|
|
@@ -3,6 +3,8 @@ import type { CollectionSlugs } from '../utils/slugs'
|
|
|
3
3
|
import { requireAdmin, handleAuthError } from '../utils/auth'
|
|
4
4
|
import { generateTicketSynthesis } from '../utils/generateTicketSynthesis'
|
|
5
5
|
import { dbFindByID } from '../utils/db'
|
|
6
|
+
import type { SupportCapabilities } from '../types'
|
|
7
|
+
import { RateLimiter, type RateLimitStore } from '../utils/rateLimiter'
|
|
6
8
|
|
|
7
9
|
/**
|
|
8
10
|
* POST /api/support/ticket-synthesis?ticketId=X[&force=true]
|
|
@@ -16,13 +18,21 @@ import { dbFindByID } from '../utils/db'
|
|
|
16
18
|
* - The hook on Tickets clears aiSummary when a ticket is reopened, so the next pass through
|
|
17
19
|
* "resolved" status will trigger a regeneration automatically.
|
|
18
20
|
*/
|
|
19
|
-
export function createTicketSynthesisEndpoint(
|
|
21
|
+
export function createTicketSynthesisEndpoint(
|
|
22
|
+
slugs: CollectionSlugs,
|
|
23
|
+
generator?: SupportCapabilities['aiSummaries'],
|
|
24
|
+
store?: RateLimitStore,
|
|
25
|
+
): Endpoint {
|
|
26
|
+
const limiter = new RateLimiter(60_000, 20, store)
|
|
20
27
|
return {
|
|
21
28
|
path: '/support/ticket-synthesis',
|
|
22
29
|
method: 'post',
|
|
23
30
|
handler: async (req) => {
|
|
24
31
|
try {
|
|
25
32
|
requireAdmin(req, slugs)
|
|
33
|
+
if (await limiter.check(String(req.user!.id), req)) {
|
|
34
|
+
return Response.json({ error: 'Rate limit exceeded' }, { status: 429 })
|
|
35
|
+
}
|
|
26
36
|
const payload = req.payload
|
|
27
37
|
|
|
28
38
|
const url = new URL(req.url || '', 'http://localhost')
|
|
@@ -54,7 +64,12 @@ export function createTicketSynthesisEndpoint(slugs: CollectionSlugs): Endpoint
|
|
|
54
64
|
}
|
|
55
65
|
}
|
|
56
66
|
|
|
57
|
-
const result =
|
|
67
|
+
const result = generator
|
|
68
|
+
? await generator.generate(payload, ticketId)
|
|
69
|
+
: await generateTicketSynthesis({ payload, slugs, ticketId })
|
|
70
|
+
if (!result) {
|
|
71
|
+
return Response.json({ error: 'Generation unavailable' }, { status: 502 })
|
|
72
|
+
}
|
|
58
73
|
return Response.json(result)
|
|
59
74
|
} catch (err) {
|
|
60
75
|
const authResponse = handleAuthError(err)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Endpoint } from 'payload'
|
|
2
2
|
import type { CollectionSlugs } from '../utils/slugs'
|
|
3
|
-
import { createHmac } from 'crypto'
|
|
3
|
+
import { createHmac, timingSafeEqual } from 'crypto'
|
|
4
4
|
import { dbFindByID, dbUpdate, dbCreate } from '../utils/db'
|
|
5
5
|
|
|
6
6
|
// 1x1 transparent GIF (43 bytes)
|
|
@@ -14,7 +14,20 @@ const TRANSPARENT_GIF = Buffer.from(
|
|
|
14
14
|
* Use this when building tracking URLs in email templates.
|
|
15
15
|
*/
|
|
16
16
|
export function generateTrackingToken(ticketId: string, messageId: string, secret: string): string {
|
|
17
|
-
return createHmac('sha256', secret).update(`${ticketId}:${messageId}`).digest('hex')
|
|
17
|
+
return createHmac('sha256', secret).update(`${ticketId}:${messageId}`).digest('hex')
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function verifyTrackingToken(
|
|
21
|
+
ticketId: string,
|
|
22
|
+
messageId: string,
|
|
23
|
+
signature: string,
|
|
24
|
+
secret: string,
|
|
25
|
+
): boolean {
|
|
26
|
+
if (!/^[0-9a-f]{64}$/i.test(signature)) return false
|
|
27
|
+
|
|
28
|
+
const expected = Buffer.from(generateTrackingToken(ticketId, messageId, secret), 'hex')
|
|
29
|
+
const received = Buffer.from(signature, 'hex')
|
|
30
|
+
return expected.length === received.length && timingSafeEqual(expected, received)
|
|
18
31
|
}
|
|
19
32
|
|
|
20
33
|
/**
|
|
@@ -40,8 +53,10 @@ export function createTrackOpenEndpoint(slugs: CollectionSlugs): Endpoint {
|
|
|
40
53
|
// and the silent "tracking writes without auth" hole.
|
|
41
54
|
const secret = process.env.PAYLOAD_SECRET || ''
|
|
42
55
|
const validSig =
|
|
43
|
-
!!secret &&
|
|
44
|
-
|
|
56
|
+
!!secret && Number.isInteger(parsedId) && parsedId > 0 &&
|
|
57
|
+
Number.isInteger(parsedMsgId) && parsedMsgId > 0 &&
|
|
58
|
+
!!ticketId && !!messageId && !!sig &&
|
|
59
|
+
verifyTrackingToken(ticketId, messageId, sig, secret)
|
|
45
60
|
|
|
46
61
|
if (!validSig) {
|
|
47
62
|
// Return transparent GIF silently (don't leak information, don't process)
|
|
@@ -59,6 +74,17 @@ export function createTrackOpenEndpoint(slugs: CollectionSlugs): Endpoint {
|
|
|
59
74
|
try {
|
|
60
75
|
const payload = req.payload
|
|
61
76
|
|
|
77
|
+
const msg = await dbFindByID(payload, slugs.ticketMessages, {
|
|
78
|
+
id: parsedMsgId,
|
|
79
|
+
depth: 0,
|
|
80
|
+
overrideAccess: true,
|
|
81
|
+
select: { ticket: true, emailOpenedAt: true },
|
|
82
|
+
}) as any
|
|
83
|
+
const messageTicketId = typeof msg?.ticket === 'object' ? msg.ticket?.id : msg?.ticket
|
|
84
|
+
if (!msg || String(messageTicketId) !== String(parsedId)) {
|
|
85
|
+
return transparentGifResponse()
|
|
86
|
+
}
|
|
87
|
+
|
|
62
88
|
const ticket = await dbFindByID(payload, slugs.tickets, {
|
|
63
89
|
id: parsedId,
|
|
64
90
|
depth: 0,
|
|
@@ -81,13 +107,6 @@ export function createTrackOpenEndpoint(slugs: CollectionSlugs): Endpoint {
|
|
|
81
107
|
|
|
82
108
|
// Track at message level
|
|
83
109
|
if (Number.isInteger(parsedMsgId) && parsedMsgId > 0) {
|
|
84
|
-
const msg = await dbFindByID(payload, slugs.ticketMessages, {
|
|
85
|
-
id: parsedMsgId,
|
|
86
|
-
depth: 0,
|
|
87
|
-
overrideAccess: true,
|
|
88
|
-
select: { emailOpenedAt: true },
|
|
89
|
-
}) as any
|
|
90
|
-
|
|
91
110
|
if (msg && !msg.emailOpenedAt) {
|
|
92
111
|
await dbUpdate(payload, slugs.ticketMessages, {
|
|
93
112
|
id: parsedMsgId,
|
|
@@ -127,16 +146,20 @@ export function createTrackOpenEndpoint(slugs: CollectionSlugs): Endpoint {
|
|
|
127
146
|
}
|
|
128
147
|
}
|
|
129
148
|
|
|
130
|
-
return
|
|
131
|
-
status: 200,
|
|
132
|
-
headers: {
|
|
133
|
-
'Content-Type': 'image/gif',
|
|
134
|
-
'Content-Length': String(TRANSPARENT_GIF.length),
|
|
135
|
-
'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0',
|
|
136
|
-
'Pragma': 'no-cache',
|
|
137
|
-
'Expires': '0',
|
|
138
|
-
},
|
|
139
|
-
})
|
|
149
|
+
return transparentGifResponse()
|
|
140
150
|
},
|
|
141
151
|
}
|
|
142
152
|
}
|
|
153
|
+
|
|
154
|
+
function transparentGifResponse(): Response {
|
|
155
|
+
return new Response(TRANSPARENT_GIF, {
|
|
156
|
+
status: 200,
|
|
157
|
+
headers: {
|
|
158
|
+
'Content-Type': 'image/gif',
|
|
159
|
+
'Content-Length': String(TRANSPARENT_GIF.length),
|
|
160
|
+
'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0',
|
|
161
|
+
'Pragma': 'no-cache',
|
|
162
|
+
'Expires': '0',
|
|
163
|
+
},
|
|
164
|
+
})
|
|
165
|
+
}
|
|
@@ -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 } from '../utils/rateLimiter'
|
|
6
|
+
import { 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
|
|
@@ -16,7 +16,6 @@ const MAX_TRANSFERS_PER_DAY = 5
|
|
|
16
16
|
// In-memory fail-closed rate limiter — enforced even when the persistent
|
|
17
17
|
// emailLogs-based check is unavailable (collection disabled). Prevents the
|
|
18
18
|
// endpoint from becoming an unbounded outbound mailer.
|
|
19
|
-
const transferLimiter = new RateLimiter(24 * 60 * 60 * 1000, MAX_TRANSFERS_PER_DAY)
|
|
20
19
|
|
|
21
20
|
/**
|
|
22
21
|
* POST /api/support/tickets/:id/transfer
|
|
@@ -33,7 +32,8 @@ const transferLimiter = new RateLimiter(24 * 60 * 60 * 1000, MAX_TRANSFERS_PER_D
|
|
|
33
32
|
* - Logs the action in `email-logs` (action='transfer') when that collection exists
|
|
34
33
|
* - Rate-limited to 5 transfers per ticket per 24h
|
|
35
34
|
*/
|
|
36
|
-
export function createTransferTicketEndpoint(slugs: CollectionSlugs): Endpoint {
|
|
35
|
+
export function createTransferTicketEndpoint(slugs: CollectionSlugs, store?: RateLimitStore): Endpoint {
|
|
36
|
+
const transferLimiter = new RateLimiter(24 * 60 * 60 * 1000, MAX_TRANSFERS_PER_DAY, store)
|
|
37
37
|
return {
|
|
38
38
|
path: '/support/tickets/:id/transfer',
|
|
39
39
|
method: 'post',
|
|
@@ -82,7 +82,7 @@ export function createTransferTicketEndpoint(slugs: CollectionSlugs): Endpoint {
|
|
|
82
82
|
|
|
83
83
|
// Fail-closed in-memory rate limit (per user + ticket): enforced even
|
|
84
84
|
// when the persistent emailLogs check below cannot run.
|
|
85
|
-
if (transferLimiter.check(`${req.user.id}:${ticketId}
|
|
85
|
+
if (await transferLimiter.check(`${req.user.id}:${ticketId}`, req)) {
|
|
86
86
|
return Response.json(
|
|
87
87
|
{ error: `Limite atteinte (${MAX_TRANSFERS_PER_DAY} transferts par 24h sur ce ticket)` },
|
|
88
88
|
{ status: 429 },
|
package/src/index.ts
CHANGED
|
@@ -23,6 +23,13 @@ export { resolveSlugs, DEFAULT_SLUGS } from './utils/slugs'
|
|
|
23
23
|
export type { CollectionSlugs } from './utils/slugs'
|
|
24
24
|
export { readSupportSettings, readUserPrefs, DEFAULT_SETTINGS, DEFAULT_USER_PREFS } from './utils/readSettings'
|
|
25
25
|
export type { SupportSettings, UserPrefs } from './utils/readSettings'
|
|
26
|
+
export { MemoryRateLimitStore, PayloadRateLimitStore, RateLimiter } from './utils/rateLimiter'
|
|
27
|
+
export type { RateLimitEntry, RateLimitStore } from './utils/rateLimiter'
|
|
28
|
+
export { DEFAULT_INBOUND_EMAIL_LIMITS, validateInboundEmailPayload, verifySecret } from './utils/webhookSecurity'
|
|
29
|
+
export type { InboundEmailInput, InboundEmailLimits, InboundEmailValidationError } from './utils/webhookSecurity'
|
|
30
|
+
|
|
31
|
+
// Endpoint factories for framework adapters (for example Next route handlers).
|
|
32
|
+
export { createLoginEndpoint, createOAuthGoogleEndpoint, createTrackOpenEndpoint } from './endpoints'
|
|
26
33
|
export { createAdminNotification } from './utils/adminNotification'
|
|
27
34
|
export { dispatchWebhook } from './utils/webhookDispatcher'
|
|
28
35
|
export { generateTicketSynthesis } from './utils/generateTicketSynthesis'
|
package/src/plugin.ts
CHANGED
|
@@ -27,7 +27,10 @@ import {
|
|
|
27
27
|
createAutomationRulesCollection,
|
|
28
28
|
createSupportTeamCollection,
|
|
29
29
|
createPushSubscriptionCollection,
|
|
30
|
+
createSupportRateLimitsCollection,
|
|
31
|
+
createSupportCountersCollection,
|
|
30
32
|
} from './collections'
|
|
33
|
+
import { PayloadRateLimitStore } from './utils/rateLimiter'
|
|
31
34
|
|
|
32
35
|
function viewConfig(component: string, path: string): AdminViewConfig {
|
|
33
36
|
return { Component: component, path: path as `/${string}` }
|
|
@@ -68,6 +71,9 @@ export function supportPlugin(config?: SupportPluginConfig): Plugin {
|
|
|
68
71
|
...config?.collectionSlugs,
|
|
69
72
|
users: config?.userCollectionSlug || 'users',
|
|
70
73
|
})
|
|
74
|
+
const rateLimitStore = config?.rateLimitStore === 'payload'
|
|
75
|
+
? new PayloadRateLimitStore(slugs.rateLimits)
|
|
76
|
+
: config?.rateLimitStore
|
|
71
77
|
|
|
72
78
|
return (incomingConfig: Config): Config => {
|
|
73
79
|
const existingCollections = incomingConfig.collections || []
|
|
@@ -80,14 +86,17 @@ export function supportPlugin(config?: SupportPluginConfig): Plugin {
|
|
|
80
86
|
projectCollectionSlug: config?.projectCollectionSlug,
|
|
81
87
|
documentsCollectionSlug: config?.documentsCollectionSlug,
|
|
82
88
|
notificationSlug: config?.notificationSlug,
|
|
89
|
+
ticketNumber: config?.ticketNumber,
|
|
90
|
+
capabilities: config?.capabilities,
|
|
83
91
|
}
|
|
84
92
|
const messageOptions = {
|
|
85
93
|
notificationSlug: config?.notificationSlug,
|
|
94
|
+
capabilities: config?.capabilities,
|
|
86
95
|
}
|
|
87
96
|
const supportCollections = [
|
|
88
97
|
createTicketsCollection(slugs, ticketOptions),
|
|
89
98
|
createTicketMessagesCollection(slugs, messageOptions),
|
|
90
|
-
createSupportClientsCollection(slugs),
|
|
99
|
+
createSupportClientsCollection(slugs, { capabilities: config?.capabilities }),
|
|
91
100
|
createCannedResponsesCollection(slugs),
|
|
92
101
|
createTicketActivityLogCollection(slugs),
|
|
93
102
|
createSatisfactionSurveysCollection(slugs),
|
|
@@ -98,7 +107,11 @@ export function supportPlugin(config?: SupportPluginConfig): Plugin {
|
|
|
98
107
|
createAutomationRulesCollection(slugs),
|
|
99
108
|
createSupportTeamCollection(slugs),
|
|
100
109
|
createPushSubscriptionCollection(slugs),
|
|
110
|
+
createSupportCountersCollection(slugs.counters),
|
|
101
111
|
]
|
|
112
|
+
if (config?.rateLimitStore === 'payload') {
|
|
113
|
+
supportCollections.push(createSupportRateLimitsCollection(slugs.rateLimits))
|
|
114
|
+
}
|
|
102
115
|
|
|
103
116
|
// Auth logs (conditional)
|
|
104
117
|
if (features.authLogs !== false) supportCollections.push(createAuthLogsCollection(slugs))
|
|
@@ -150,6 +163,8 @@ export function supportPlugin(config?: SupportPluginConfig): Plugin {
|
|
|
150
163
|
const supportEndpoints = createSupportEndpoints(slugs, {
|
|
151
164
|
oauth: { allowedEmailDomains: config?.allowedEmailDomains },
|
|
152
165
|
features,
|
|
166
|
+
rateLimitStore,
|
|
167
|
+
capabilities: config?.capabilities,
|
|
153
168
|
})
|
|
154
169
|
|
|
155
170
|
return {
|
package/src/portal/LiveChat.tsx
CHANGED
|
@@ -441,7 +441,7 @@ export function LiveChat() {
|
|
|
441
441
|
width: 380,
|
|
442
442
|
maxWidth: 'calc(100vw - 32px)',
|
|
443
443
|
height: screen === 'identify' || screen === 'rating' ? 'auto' : 520,
|
|
444
|
-
maxHeight: 'calc(
|
|
444
|
+
maxHeight: 'calc(100dvh - 32px)',
|
|
445
445
|
borderRadius: 16,
|
|
446
446
|
border: '3px solid #000',
|
|
447
447
|
backgroundColor: '#fff',
|
|
@@ -215,7 +215,7 @@ function ChatWidgetInner() {
|
|
|
215
215
|
{!isOpen && (
|
|
216
216
|
<button
|
|
217
217
|
onClick={() => setIsOpen(true)}
|
|
218
|
-
className="fixed bottom-6 right-6 z-50 flex h-14 w-14 items-center justify-center rounded-full border-3 border-black bg-[#00E5FF] shadow-[4px_4px_0px_#000] transition-
|
|
218
|
+
className="fixed bottom-6 right-6 z-50 flex h-14 w-14 items-center justify-center rounded-full border-3 border-black bg-[#00E5FF] 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] dark:border-gray-600 dark:shadow-[4px_4px_0px_#333]"
|
|
219
219
|
title="Chat en direct"
|
|
220
220
|
>
|
|
221
221
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" className="h-6 w-6">
|
|
@@ -274,7 +274,7 @@ function ChatWidgetInner() {
|
|
|
274
274
|
</p>
|
|
275
275
|
<button
|
|
276
276
|
onClick={startChat}
|
|
277
|
-
className="rounded-xl border-3 border-black bg-[#00E5FF] px-6 py-3 text-sm font-black text-black shadow-[4px_4px_0px_#000] transition-
|
|
277
|
+
className="rounded-xl border-3 border-black bg-[#00E5FF] px-6 py-3 text-sm 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]"
|
|
278
278
|
>
|
|
279
279
|
Démarrer le chat
|
|
280
280
|
</button>
|
|
@@ -320,7 +320,7 @@ function ChatWidgetInner() {
|
|
|
320
320
|
<p className="mb-3 text-sm text-gray-500 dark:text-gray-400">Le chat est terminé.</p>
|
|
321
321
|
<button
|
|
322
322
|
onClick={resetChat}
|
|
323
|
-
className="rounded-xl border-2 border-black bg-[#FFD600] px-4 py-2 text-xs font-bold text-black shadow-[2px_2px_0px_#000] transition-
|
|
323
|
+
className="rounded-xl border-2 border-black bg-[#FFD600] px-4 py-2 text-xs font-bold text-black shadow-[2px_2px_0px_#000] transition-[color,background-color,border-color,box-shadow,transform] motion-reduce:transition-none hover:translate-x-[1px] hover:translate-y-[1px] hover:shadow-[1px_1px_0px_#000]"
|
|
324
324
|
>
|
|
325
325
|
Nouveau chat
|
|
326
326
|
</button>
|
|
@@ -344,7 +344,7 @@ function ChatWidgetInner() {
|
|
|
344
344
|
<button
|
|
345
345
|
type="submit"
|
|
346
346
|
disabled={!input.trim() || sending}
|
|
347
|
-
className="rounded-xl border-2 border-black bg-[#00E5FF] px-3 py-2 font-bold text-black transition-
|
|
347
|
+
className="rounded-xl border-2 border-black bg-[#00E5FF] px-3 py-2 font-bold text-black transition-[color,background-color,border-color,box-shadow,transform] motion-reduce:transition-none hover:shadow-[2px_2px_0px_#000] disabled:opacity-50 dark:border-gray-600"
|
|
348
348
|
>
|
|
349
349
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" className="h-4 w-4">
|
|
350
350
|
<line x1="22" y1="2" x2="11" y2="13" />
|