@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.
- package/README.md +272 -35
- package/dist/components/RichTextEditor/index.js +1 -1
- package/dist/components/TicketConversation/RewriteDropdown.js +1 -1
- package/dist/components/TicketConversation/components/AISummaryPanel.js +3 -0
- package/dist/components/TicketConversation/components/ActionPanels.js +13 -8
- package/dist/components/TicketConversation/components/ClientHistory.js +1 -0
- package/dist/components/TicketConversation/components/CodeBlock.js +1 -0
- package/dist/components/TicketConversation/components/CodeBlockInserter.js +1 -1
- package/dist/components/TicketConversation/components/QuickActions.js +10 -8
- package/dist/components/TicketConversation/components/TicketHeader.js +2 -0
- package/dist/components/TicketConversation/components/TimeTrackingPanel.js +15 -9
- package/dist/components/TicketConversation/index.d.ts +10 -0
- package/dist/components/TicketConversation/index.js +25 -7
- package/dist/index.cjs +421 -73
- package/dist/index.d.cts +127 -38
- package/dist/index.d.ts +127 -38
- package/dist/index.js +415 -72
- package/dist/styles/BillingView.module.scss +11 -11
- package/dist/styles/ChatView.module.scss +7 -7
- package/dist/styles/CommandPalette.module.scss +1 -1
- package/dist/styles/CrmView.module.scss +9 -9
- package/dist/styles/EmailTracking.module.scss +6 -6
- package/dist/styles/ImportConversation.module.scss +5 -5
- package/dist/styles/Logs.module.scss +5 -5
- package/dist/styles/NewTicket.module.scss +2 -2
- package/dist/styles/PendingEmails.module.scss +13 -13
- package/dist/styles/SupportDashboard.module.scss +24 -8
- package/dist/styles/TicketDetail.module.scss +20 -20
- package/dist/styles/TicketInbox.module.scss +6 -6
- package/dist/styles/TicketingSettings.module.scss +10 -10
- package/dist/styles/TimeDashboard.module.scss +6 -6
- package/dist/styles/_tokens.scss +3 -1
- package/dist/utils/db.d.ts +34 -0
- package/dist/utils/readSettings.d.ts +90 -0
- package/dist/views/BillingView/client.js +15 -8
- package/dist/views/BillingView/index.js +4 -4
- package/dist/views/ChatView/client.js +2 -0
- package/dist/views/ChatView/index.js +4 -4
- package/dist/views/CrmView/client.js +2 -0
- package/dist/views/CrmView/index.js +4 -4
- package/dist/views/EmailTrackingView/client.js +23 -11
- package/dist/views/EmailTrackingView/index.js +4 -4
- package/dist/views/ImportConversationView/client.js +1 -0
- package/dist/views/ImportConversationView/index.js +4 -4
- package/dist/views/LogsView/client.js +1 -1
- package/dist/views/LogsView/index.js +4 -2
- package/dist/views/NewTicketView/client.js +20 -13
- package/dist/views/NewTicketView/index.js +4 -2
- package/dist/views/PendingEmailsView/client.js +9 -3
- package/dist/views/PendingEmailsView/index.js +4 -4
- package/dist/views/SupportDashboardView/client.js +17 -19
- package/dist/views/SupportDashboardView/index.js +4 -4
- package/dist/views/TicketDetailView/client.js +23 -11
- package/dist/views/TicketDetailView/index.js +4 -4
- package/dist/views/TicketInboxView/client.js +52 -26
- package/dist/views/TicketInboxView/index.js +4 -2
- package/dist/views/TicketingSettingsView/TicketingSettings.module.scss +7 -7
- package/dist/views/TicketingSettingsView/client.js +44 -26
- package/dist/views/TicketingSettingsView/index.js +4 -4
- package/dist/views/TimeDashboardView/client.js +10 -7
- package/dist/views/TimeDashboardView/index.js +4 -4
- package/dist/views/shared/ErrorBoundary.d.ts +36 -1
- package/dist/views/shared/ErrorBoundary.js +59 -28
- package/dist/views/shared/adminTokens.d.ts +15 -5
- package/dist/views/shared/adminTokens.js +23 -7
- package/dist/views/shared/icons.d.ts +49 -0
- package/dist/views/shared/icons.js +92 -0
- package/dist/views/shared/locales/en.json +15 -1
- package/dist/views/shared/locales/fr.json +15 -1
- package/dist/views/shared/viewAccess.d.ts +29 -0
- package/dist/views/shared/viewAccess.js +24 -0
- package/package.json +31 -21
- package/scripts/uninstall-data.mjs +178 -0
- package/scripts/uninstall.mjs +184 -0
- package/src/collections/Tickets.ts +7 -0
- package/src/collections/TimeEntries.ts +11 -3
- package/src/components/RichTextEditor/index.tsx +1 -1
- package/src/components/TicketConversation/RewriteDropdown.tsx +1 -1
- package/src/components/TicketConversation/components/AISummaryPanel.tsx +3 -0
- package/src/components/TicketConversation/components/ActionPanels.tsx +13 -8
- package/src/components/TicketConversation/components/ClientHistory.tsx +1 -0
- package/src/components/TicketConversation/components/CodeBlock.tsx +1 -0
- package/src/components/TicketConversation/components/CodeBlockInserter.tsx +1 -1
- package/src/components/TicketConversation/components/QuickActions.tsx +10 -8
- package/src/components/TicketConversation/components/TicketHeader.tsx +2 -0
- package/src/components/TicketConversation/components/TimeTrackingPanel.tsx +17 -9
- package/src/components/TicketConversation/index.tsx +41 -7
- package/src/endpoints/auth-2fa.ts +53 -8
- package/src/endpoints/capabilities.ts +2 -4
- package/src/endpoints/chatbot.ts +2 -2
- package/src/endpoints/delete-account.ts +191 -47
- package/src/endpoints/export-data.ts +104 -4
- package/src/endpoints/import-conversation.ts +2 -2
- package/src/endpoints/login.ts +13 -3
- package/src/endpoints/purge-logs.ts +6 -15
- package/src/endpoints/push.ts +14 -1
- package/src/endpoints/statuses.ts +17 -0
- package/src/index.ts +2 -0
- package/src/plugin.ts +51 -1
- package/src/portal/LiveChat.tsx +1 -1
- package/src/portal/auth/profile/page.tsx +3 -2
- package/src/portal/icons.tsx +91 -0
- package/src/portal/login/page.tsx +14 -4
- package/src/styles/BillingView.module.scss +11 -11
- package/src/styles/ChatView.module.scss +7 -7
- package/src/styles/CommandPalette.module.scss +1 -1
- package/src/styles/CrmView.module.scss +9 -9
- package/src/styles/EmailTracking.module.scss +6 -6
- package/src/styles/ImportConversation.module.scss +5 -5
- package/src/styles/Logs.module.scss +5 -5
- package/src/styles/NewTicket.module.scss +2 -2
- package/src/styles/PendingEmails.module.scss +13 -13
- package/src/styles/SupportDashboard.module.scss +24 -8
- package/src/styles/TicketDetail.module.scss +20 -20
- package/src/styles/TicketInbox.module.scss +6 -6
- package/src/styles/TicketingSettings.module.scss +10 -10
- package/src/styles/TimeDashboard.module.scss +6 -6
- package/src/styles/_tokens.scss +3 -1
- package/src/types.ts +20 -0
- package/src/utils/push.ts +22 -0
- package/src/utils/rateLimiter.ts +98 -0
- package/src/utils/retention.ts +111 -0
- package/src/utils/slugs.ts +15 -0
- package/src/utils/twoFactorChallenge.ts +6 -1
- package/src/utils/urlSafety.ts +36 -1
- package/src/views/BillingView/client.tsx +17 -8
- package/src/views/BillingView/index.tsx +4 -4
- package/src/views/ChatView/client.tsx +2 -0
- package/src/views/ChatView/index.tsx +4 -4
- package/src/views/CrmView/client.tsx +2 -0
- package/src/views/CrmView/index.tsx +4 -4
- package/src/views/EmailTrackingView/client.tsx +18 -7
- package/src/views/EmailTrackingView/index.tsx +4 -4
- package/src/views/ImportConversationView/client.tsx +1 -0
- package/src/views/ImportConversationView/index.tsx +4 -4
- package/src/views/LogsView/client.tsx +1 -1
- package/src/views/LogsView/index.tsx +4 -2
- package/src/views/NewTicketView/client.tsx +22 -13
- package/src/views/NewTicketView/index.tsx +4 -2
- package/src/views/PendingEmailsView/client.tsx +9 -3
- package/src/views/PendingEmailsView/index.tsx +4 -4
- package/src/views/SupportDashboardView/client.tsx +11 -5
- package/src/views/SupportDashboardView/index.tsx +4 -4
- package/src/views/TicketDetailView/client.tsx +25 -11
- package/src/views/TicketDetailView/index.tsx +4 -4
- package/src/views/TicketInboxView/client.tsx +47 -7
- package/src/views/TicketInboxView/index.tsx +4 -2
- package/src/views/TicketingSettingsView/TicketingSettings.module.scss +7 -7
- package/src/views/TicketingSettingsView/client.tsx +201 -144
- package/src/views/TicketingSettingsView/index.tsx +4 -4
- package/src/views/TimeDashboardView/client.tsx +10 -7
- package/src/views/TimeDashboardView/index.tsx +4 -4
- package/src/views/shared/ErrorBoundary.tsx +95 -31
- package/src/views/shared/adminTokens.ts +28 -11
- package/src/views/shared/icons.tsx +146 -0
- package/src/views/shared/locales/en.json +15 -1
- package/src/views/shared/locales/fr.json +15 -1
- package/src/views/shared/viewAccess.ts +73 -0
- package/src/types/lucide-react.d.ts +0 -42
|
@@ -1,13 +1,37 @@
|
|
|
1
1
|
import type { Endpoint } from 'payload'
|
|
2
|
+
import { commitTransaction, initTransaction, killTransaction } from 'payload'
|
|
2
3
|
import type { CollectionSlugs } from '../utils/slugs'
|
|
4
|
+
import { FIXED_SLUGS } from '../utils/slugs'
|
|
3
5
|
import { requireClient, handleAuthError } from '../utils/auth'
|
|
4
6
|
import { dbFind, dbDelete } from '../utils/db'
|
|
5
7
|
|
|
8
|
+
interface AttachmentRow {
|
|
9
|
+
file?: number | string | { id?: number | string } | null
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Collect the media ids referenced by an `attachments` array field. */
|
|
13
|
+
function collectAttachmentIds(
|
|
14
|
+
docs: Array<{ attachments?: AttachmentRow[] | null }>,
|
|
15
|
+
into: Set<number | string>,
|
|
16
|
+
): void {
|
|
17
|
+
for (const doc of docs) {
|
|
18
|
+
for (const attachment of doc.attachments ?? []) {
|
|
19
|
+
const file = attachment?.file
|
|
20
|
+
const id = file != null && typeof file === 'object' ? file.id : file
|
|
21
|
+
if (id != null) into.add(id)
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
6
26
|
/**
|
|
7
27
|
* POST /api/support/delete-account
|
|
8
28
|
* RGPD — Right to erasure (Article 17).
|
|
9
|
-
*
|
|
10
|
-
*
|
|
29
|
+
*
|
|
30
|
+
* Erases every row the plugin holds about the caller, and the uploaded files
|
|
31
|
+
* those rows point at. The whole sequence runs inside a single Payload
|
|
32
|
+
* transaction: a failure halfway through must not leave a half-deleted
|
|
33
|
+
* account, which would be worse than not deleting at all — the person would
|
|
34
|
+
* believe their data is gone while some of it is still readable.
|
|
11
35
|
*/
|
|
12
36
|
export function createDeleteAccountEndpoint(slugs: CollectionSlugs): Endpoint {
|
|
13
37
|
return {
|
|
@@ -29,7 +53,8 @@ export function createDeleteAccountEndpoint(slugs: CollectionSlugs): Endpoint {
|
|
|
29
53
|
)
|
|
30
54
|
}
|
|
31
55
|
|
|
32
|
-
// Verify password
|
|
56
|
+
// Verify password. Deliberately outside the transaction: it is a read,
|
|
57
|
+
// and `payload.login` writes nothing we would want to roll back.
|
|
33
58
|
try {
|
|
34
59
|
await payload.login({
|
|
35
60
|
collection: slugs.supportClients as any,
|
|
@@ -43,59 +68,178 @@ export function createDeleteAccountEndpoint(slugs: CollectionSlugs): Endpoint {
|
|
|
43
68
|
}
|
|
44
69
|
|
|
45
70
|
const clientId = req.user.id
|
|
71
|
+
const clientEmail = req.user.email
|
|
46
72
|
|
|
47
|
-
//
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
const ticketIds = tickets.docs.map((t) => t.id)
|
|
57
|
-
|
|
58
|
-
// 2. Batch delete related data using where clauses
|
|
59
|
-
if (ticketIds.length > 0) {
|
|
60
|
-
await dbDelete(payload, slugs.ticketMessages, {
|
|
61
|
-
where: { ticket: { in: ticketIds } },
|
|
62
|
-
overrideAccess: true,
|
|
63
|
-
})
|
|
73
|
+
// Several of the collections below only exist when the matching
|
|
74
|
+
// feature flag is on (chat, pendingEmails, ai, timeTracking,
|
|
75
|
+
// authLogs, emailTracking). Addressing one that was never registered
|
|
76
|
+
// throws, and inside a transaction that would roll the whole erasure
|
|
77
|
+
// back — so an install with chat disabled could never delete an
|
|
78
|
+
// account at all.
|
|
79
|
+
const isRegistered = (slug: string): boolean =>
|
|
80
|
+
Boolean((payload.collections as Record<string, unknown> | undefined)?.[slug])
|
|
64
81
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
82
|
+
// No-op on adapters that do not expose `beginTransaction` — the SQLite
|
|
83
|
+
// adapter only does when the host passes `transactionOptions`. The
|
|
84
|
+
// sequence then runs unwrapped, exactly as it did before.
|
|
85
|
+
const startedTransaction = await initTransaction(req)
|
|
69
86
|
|
|
70
|
-
|
|
71
|
-
|
|
87
|
+
try {
|
|
88
|
+
// 1. Tickets owned by the client.
|
|
89
|
+
const tickets = await dbFind(payload, slugs.tickets, {
|
|
90
|
+
where: { client: { equals: clientId } },
|
|
91
|
+
limit: 10000,
|
|
92
|
+
depth: 0,
|
|
72
93
|
overrideAccess: true,
|
|
94
|
+
select: { id: true },
|
|
95
|
+
req,
|
|
73
96
|
})
|
|
74
97
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
98
|
+
const ticketIds = tickets.docs.map((t) => t.id)
|
|
99
|
+
|
|
100
|
+
// 2. Uploaded files. They live in the HOST's media collection, so
|
|
101
|
+
// their ids have to be gathered before the rows that reference them
|
|
102
|
+
// are gone.
|
|
103
|
+
const attachmentIds = new Set<number | string>()
|
|
104
|
+
|
|
105
|
+
if (ticketIds.length > 0) {
|
|
106
|
+
const messages = await dbFind<{ attachments?: AttachmentRow[] | null }>(
|
|
107
|
+
payload,
|
|
108
|
+
slugs.ticketMessages,
|
|
109
|
+
{
|
|
110
|
+
where: { ticket: { in: ticketIds } },
|
|
111
|
+
limit: 10000,
|
|
112
|
+
depth: 0,
|
|
113
|
+
overrideAccess: true,
|
|
114
|
+
req,
|
|
115
|
+
},
|
|
116
|
+
)
|
|
117
|
+
collectAttachmentIds(messages.docs, attachmentIds)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (isRegistered(slugs.pendingEmails)) {
|
|
121
|
+
const pendingEmails = await dbFind<{ attachments?: AttachmentRow[] | null }>(
|
|
122
|
+
payload,
|
|
123
|
+
slugs.pendingEmails,
|
|
124
|
+
{
|
|
125
|
+
where: { client: { equals: clientId } },
|
|
126
|
+
limit: 10000,
|
|
127
|
+
depth: 0,
|
|
128
|
+
overrideAccess: true,
|
|
129
|
+
req,
|
|
130
|
+
},
|
|
131
|
+
)
|
|
132
|
+
collectAttachmentIds(pendingEmails.docs, attachmentIds)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// 3. Rows that point at one of those tickets. They must go BEFORE
|
|
136
|
+
// the tickets themselves: their `ticket` relationship is required,
|
|
137
|
+
// so the FK would fail on the ticket delete — and Payload's
|
|
138
|
+
// delete-many collects per-document errors instead of throwing, so
|
|
139
|
+
// the failure would be silent and the account would survive.
|
|
140
|
+
if (ticketIds.length > 0) {
|
|
141
|
+
for (const slug of [FIXED_SLUGS.ticketFeedback, FIXED_SLUGS.ticketCollaborators]) {
|
|
142
|
+
if (!isRegistered(slug)) continue
|
|
143
|
+
await dbDelete(payload, slug, {
|
|
144
|
+
where: {
|
|
145
|
+
or: [
|
|
146
|
+
{ ticket: { in: ticketIds } },
|
|
147
|
+
{ client: { equals: clientId } },
|
|
148
|
+
],
|
|
149
|
+
},
|
|
150
|
+
overrideAccess: true,
|
|
151
|
+
req,
|
|
152
|
+
})
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// 4. The tickets. Their own beforeDelete cascade takes the messages,
|
|
157
|
+
// the activity log, the time entries and the surveys with them.
|
|
158
|
+
if (ticketIds.length > 0) {
|
|
159
|
+
await dbDelete(payload, slugs.tickets, {
|
|
160
|
+
where: { client: { equals: clientId } },
|
|
161
|
+
overrideAccess: true,
|
|
162
|
+
req,
|
|
163
|
+
})
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// 5. Rows keyed on the client id, whatever ticket they belong to.
|
|
167
|
+
for (const slug of [
|
|
168
|
+
slugs.satisfactionSurveys,
|
|
169
|
+
slugs.chatMessages,
|
|
170
|
+
slugs.notificationQueue,
|
|
171
|
+
slugs.pendingEmails,
|
|
172
|
+
FIXED_SLUGS.clientSummaries,
|
|
173
|
+
FIXED_SLUGS.ticketFeedback,
|
|
174
|
+
]) {
|
|
175
|
+
if (!isRegistered(slug)) continue
|
|
176
|
+
await dbDelete(payload, slug, {
|
|
177
|
+
where: { client: { equals: clientId } },
|
|
178
|
+
overrideAccess: true,
|
|
179
|
+
req,
|
|
180
|
+
})
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Collaborator invitations can predate the account they point at, so
|
|
184
|
+
// they are also matched on the address alone.
|
|
185
|
+
if (isRegistered(FIXED_SLUGS.ticketCollaborators)) {
|
|
186
|
+
await dbDelete(payload, FIXED_SLUGS.ticketCollaborators, {
|
|
187
|
+
where: {
|
|
188
|
+
or: [
|
|
189
|
+
{ client: { equals: clientId } },
|
|
190
|
+
...(clientEmail ? [{ email: { equals: clientEmail } }] : []),
|
|
191
|
+
],
|
|
192
|
+
},
|
|
193
|
+
overrideAccess: true,
|
|
194
|
+
req,
|
|
195
|
+
})
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// 6. Journals, keyed on the address only.
|
|
199
|
+
if (clientEmail) {
|
|
200
|
+
if (isRegistered(slugs.authLogs)) {
|
|
201
|
+
await dbDelete(payload, slugs.authLogs, {
|
|
202
|
+
where: { email: { equals: clientEmail } },
|
|
203
|
+
overrideAccess: true,
|
|
204
|
+
req,
|
|
205
|
+
})
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (isRegistered(slugs.emailLogs)) {
|
|
209
|
+
await dbDelete(payload, slugs.emailLogs, {
|
|
210
|
+
where: {
|
|
211
|
+
or: [
|
|
212
|
+
{ senderEmail: { equals: clientEmail } },
|
|
213
|
+
{ recipientEmail: { equals: clientEmail } },
|
|
214
|
+
],
|
|
215
|
+
},
|
|
216
|
+
overrideAccess: true,
|
|
217
|
+
req,
|
|
218
|
+
})
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// 7. The uploaded files themselves.
|
|
223
|
+
for (const id of isRegistered(slugs.media) ? attachmentIds : []) {
|
|
224
|
+
await dbDelete(payload, slugs.media, {
|
|
225
|
+
id,
|
|
226
|
+
overrideAccess: true,
|
|
227
|
+
req,
|
|
228
|
+
})
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// 8. The account.
|
|
232
|
+
await dbDelete(payload, slugs.supportClients, {
|
|
233
|
+
id: clientId,
|
|
78
234
|
overrideAccess: true,
|
|
235
|
+
req,
|
|
79
236
|
})
|
|
80
|
-
}
|
|
81
237
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
// 4. Batch delete chat messages
|
|
89
|
-
await dbDelete(payload, slugs.chatMessages, {
|
|
90
|
-
where: { client: { equals: clientId } },
|
|
91
|
-
overrideAccess: true,
|
|
92
|
-
})
|
|
93
|
-
|
|
94
|
-
// 5. Delete the client account
|
|
95
|
-
await dbDelete(payload, slugs.supportClients, {
|
|
96
|
-
id: clientId,
|
|
97
|
-
overrideAccess: true,
|
|
98
|
-
})
|
|
238
|
+
if (startedTransaction) await commitTransaction(req)
|
|
239
|
+
} catch (err) {
|
|
240
|
+
if (startedTransaction) await killTransaction(req)
|
|
241
|
+
throw err
|
|
242
|
+
}
|
|
99
243
|
|
|
100
244
|
const headers = new Headers({ 'Content-Type': 'application/json' })
|
|
101
245
|
const secure = process.env.NODE_ENV === 'production'
|
|
@@ -1,11 +1,21 @@
|
|
|
1
1
|
import type { Endpoint } from 'payload'
|
|
2
2
|
import type { CollectionSlugs } from '../utils/slugs'
|
|
3
|
+
import { FIXED_SLUGS } from '../utils/slugs'
|
|
3
4
|
import { requireClient, handleAuthError } from '../utils/auth'
|
|
4
5
|
import { dbFind, dbFindByID } from '../utils/db'
|
|
5
6
|
|
|
7
|
+
const EMPTY = { docs: [] as any[] }
|
|
8
|
+
|
|
6
9
|
/**
|
|
7
10
|
* GET /api/support/export-data
|
|
8
11
|
* RGPD Data Export — allows support clients to download all their personal data.
|
|
12
|
+
*
|
|
13
|
+
* The payload is split in two. `providedByYou` is what article 20
|
|
14
|
+
* (portability) covers: what the person or their correspondents typed.
|
|
15
|
+
* `derivedData` is what the plugin computed ABOUT them — the AI client
|
|
16
|
+
* summary. Article 20 does not cover derived data; article 15 (access) does,
|
|
17
|
+
* and article 17 (erasure) does too, which is why `delete-account` removes
|
|
18
|
+
* `client-summaries` as well. Merging the two sections would blur that.
|
|
9
19
|
*/
|
|
10
20
|
export function createExportDataEndpoint(slugs: CollectionSlugs): Endpoint {
|
|
11
21
|
return {
|
|
@@ -17,7 +27,20 @@ export function createExportDataEndpoint(slugs: CollectionSlugs): Endpoint {
|
|
|
17
27
|
|
|
18
28
|
requireClient(req, slugs)
|
|
19
29
|
|
|
20
|
-
|
|
30
|
+
// Feature-flagged collections are not registered when their flag is
|
|
31
|
+
// off; addressing one that does not exist throws.
|
|
32
|
+
const isRegistered = (slug: string): boolean =>
|
|
33
|
+
Boolean((payload.collections as Record<string, unknown> | undefined)?.[slug])
|
|
34
|
+
|
|
35
|
+
const [
|
|
36
|
+
clientData,
|
|
37
|
+
ticketsResult,
|
|
38
|
+
messagesResult,
|
|
39
|
+
surveysResult,
|
|
40
|
+
chatResult,
|
|
41
|
+
feedbackResult,
|
|
42
|
+
summariesResult,
|
|
43
|
+
] = await Promise.all([
|
|
21
44
|
dbFindByID(payload, slugs.supportClients, {
|
|
22
45
|
id: req.user.id,
|
|
23
46
|
depth: 0,
|
|
@@ -44,12 +67,46 @@ export function createExportDataEndpoint(slugs: CollectionSlugs): Endpoint {
|
|
|
44
67
|
depth: 0,
|
|
45
68
|
overrideAccess: true,
|
|
46
69
|
}),
|
|
70
|
+
isRegistered(slugs.chatMessages)
|
|
71
|
+
? dbFind(payload, slugs.chatMessages, {
|
|
72
|
+
where: { client: { equals: req.user.id } },
|
|
73
|
+
limit: 5000,
|
|
74
|
+
depth: 0,
|
|
75
|
+
overrideAccess: true,
|
|
76
|
+
})
|
|
77
|
+
: EMPTY,
|
|
78
|
+
isRegistered(FIXED_SLUGS.ticketFeedback)
|
|
79
|
+
? dbFind(payload, FIXED_SLUGS.ticketFeedback, {
|
|
80
|
+
where: { client: { equals: req.user.id } },
|
|
81
|
+
limit: 500,
|
|
82
|
+
depth: 0,
|
|
83
|
+
overrideAccess: true,
|
|
84
|
+
})
|
|
85
|
+
: EMPTY,
|
|
86
|
+
isRegistered(FIXED_SLUGS.clientSummaries)
|
|
87
|
+
? dbFind(payload, FIXED_SLUGS.clientSummaries, {
|
|
88
|
+
where: { client: { equals: req.user.id } },
|
|
89
|
+
limit: 100,
|
|
90
|
+
depth: 0,
|
|
91
|
+
overrideAccess: true,
|
|
92
|
+
})
|
|
93
|
+
: EMPTY,
|
|
47
94
|
])
|
|
48
95
|
|
|
96
|
+
const ticketIds = ticketsResult.docs.map((t: any) => t.id)
|
|
97
|
+
|
|
98
|
+
// Time entries hang off the tickets, not off the client.
|
|
99
|
+
const timeResult = ticketIds.length > 0 && isRegistered(slugs.timeEntries)
|
|
100
|
+
? await dbFind(payload, slugs.timeEntries, {
|
|
101
|
+
where: { ticket: { in: ticketIds } },
|
|
102
|
+
limit: 5000,
|
|
103
|
+
depth: 0,
|
|
104
|
+
overrideAccess: true,
|
|
105
|
+
})
|
|
106
|
+
: EMPTY
|
|
107
|
+
|
|
49
108
|
const c = clientData as any
|
|
50
|
-
const
|
|
51
|
-
exportDate: new Date().toISOString(),
|
|
52
|
-
exportType: 'RGPD - Export des données personnelles',
|
|
109
|
+
const providedByYou = {
|
|
53
110
|
profile: {
|
|
54
111
|
email: c.email,
|
|
55
112
|
firstName: c.firstName,
|
|
@@ -79,6 +136,49 @@ export function createExportDataEndpoint(slugs: CollectionSlugs): Endpoint {
|
|
|
79
136
|
comment: s.comment,
|
|
80
137
|
createdAt: s.createdAt,
|
|
81
138
|
})),
|
|
139
|
+
feedback: feedbackResult.docs.map((f: any) => ({
|
|
140
|
+
ticketId: f.ticket,
|
|
141
|
+
rating: f.rating,
|
|
142
|
+
comment: f.comment,
|
|
143
|
+
submittedFrom: f.submittedFrom,
|
|
144
|
+
createdAt: f.createdAt,
|
|
145
|
+
})),
|
|
146
|
+
chatMessages: chatResult.docs.map((m: any) => ({
|
|
147
|
+
session: m.session,
|
|
148
|
+
senderType: m.senderType,
|
|
149
|
+
message: m.message,
|
|
150
|
+
createdAt: m.createdAt,
|
|
151
|
+
})),
|
|
152
|
+
timeEntries: timeResult.docs.map((e: any) => ({
|
|
153
|
+
ticketId: e.ticket,
|
|
154
|
+
duration: e.duration,
|
|
155
|
+
date: e.date,
|
|
156
|
+
description: e.description,
|
|
157
|
+
billable: e.billable,
|
|
158
|
+
})),
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const exportData = {
|
|
162
|
+
exportDate: new Date().toISOString(),
|
|
163
|
+
exportType: 'RGPD - Export des données personnelles',
|
|
164
|
+
providedByYou: {
|
|
165
|
+
legalBasis: 'Art. 20 RGPD - droit à la portabilité',
|
|
166
|
+
...providedByYou,
|
|
167
|
+
},
|
|
168
|
+
derivedData: {
|
|
169
|
+
legalBasis: 'Art. 15 RGPD - droit d\'accès (données dérivées, hors portabilité)',
|
|
170
|
+
clientSummaries: summariesResult.docs.map((s: any) => ({
|
|
171
|
+
summary: s.summary,
|
|
172
|
+
recurringTopics: s.recurringTopics,
|
|
173
|
+
patterns: s.patterns,
|
|
174
|
+
keyFacts: s.keyFacts,
|
|
175
|
+
ticketCount: s.ticketCount,
|
|
176
|
+
messageCount: s.messageCount,
|
|
177
|
+
averageSatisfaction: s.averageSatisfaction,
|
|
178
|
+
generatedAt: s.generatedAt,
|
|
179
|
+
aiModel: s.aiModel,
|
|
180
|
+
})),
|
|
181
|
+
},
|
|
82
182
|
}
|
|
83
183
|
|
|
84
184
|
const json = JSON.stringify(exportData, null, 2)
|
|
@@ -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 { readSupportSettings } from '../utils/readSettings'
|
|
5
5
|
import { dbFind, dbCreate } from '../utils/db'
|
|
6
6
|
import { verifySecret } from '../utils/webhookSecurity'
|
|
@@ -154,7 +154,7 @@ export function createImportConversationEndpoint(slugs: CollectionSlugs, store?:
|
|
|
154
154
|
return Response.json({ error: 'Unauthorized' }, { status: 401 })
|
|
155
155
|
}
|
|
156
156
|
|
|
157
|
-
const ip = req
|
|
157
|
+
const ip = clientIpRateKey(req)
|
|
158
158
|
if (await importLimiter.check(ip, req)) {
|
|
159
159
|
return Response.json({ error: 'Rate limit exceeded. Maximum 10 imports per hour.' }, { status: 429 })
|
|
160
160
|
}
|
package/src/endpoints/login.ts
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
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 { dbCreate } from '../utils/db'
|
|
5
5
|
import { issueTwoFactorChallenge } from '../utils/twoFactorChallenge'
|
|
6
6
|
|
|
7
7
|
|
|
8
|
+
/** Enough to identify a browser, short enough that a flood cannot fill the disk. */
|
|
9
|
+
const MAX_LOGGED_USER_AGENT = 256
|
|
10
|
+
|
|
8
11
|
/**
|
|
9
12
|
* POST /api/support/login
|
|
10
13
|
* Client login endpoint.
|
|
@@ -18,7 +21,12 @@ export function createLoginEndpoint(slugs: CollectionSlugs, store?: RateLimitSto
|
|
|
18
21
|
// NOTE: x-forwarded-for is spoofable unless this app sits strictly behind a
|
|
19
22
|
// trusted proxy that rewrites it. This IP rate-limit is a SECONDARY defense;
|
|
20
23
|
// the primary brute-force control is Payload's account lock (maxLoginAttempts).
|
|
21
|
-
|
|
24
|
+
//
|
|
25
|
+
// `clientIpRateKey` is what keeps a spoofed header from being more than a
|
|
26
|
+
// bucket choice: the raw header used to become the limiter key AND the
|
|
27
|
+
// `ipAddress` column below, so rotating it minted unbounded map entries in
|
|
28
|
+
// memory and unbounded rows on disk.
|
|
29
|
+
const ip = clientIpRateKey(req)
|
|
22
30
|
|
|
23
31
|
if (await loginLimiter.check(ip, req)) {
|
|
24
32
|
return Response.json(
|
|
@@ -35,7 +43,9 @@ export function createLoginEndpoint(slugs: CollectionSlugs, store?: RateLimitSto
|
|
|
35
43
|
return Response.json({ error: 'Invalid JSON body' }, { status: 400 })
|
|
36
44
|
}
|
|
37
45
|
const { email, password } = body
|
|
38
|
-
|
|
46
|
+
// Persisted on every failed attempt, from an anonymous request: bounded so
|
|
47
|
+
// a flood cannot write tens of kilobytes per row into `auth-logs`.
|
|
48
|
+
const userAgent = (req.headers.get('user-agent') || '').slice(0, MAX_LOGGED_USER_AGENT)
|
|
39
49
|
|
|
40
50
|
if (!email || !password) {
|
|
41
51
|
return Response.json({ error: 'Email et mot de passe requis.' }, { status: 400 })
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { Endpoint } from 'payload'
|
|
2
2
|
import type { CollectionSlugs } from '../utils/slugs'
|
|
3
3
|
import { requireAdmin, handleAuthError } from '../utils/auth'
|
|
4
|
-
import {
|
|
4
|
+
import { purgeOlderThan, purgeableCollections } from '../utils/retention'
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* DELETE /api/support/purge-logs?collection=email-logs&days=30
|
|
@@ -22,25 +22,16 @@ export function createPurgeLogsEndpoint(slugs: CollectionSlugs): Endpoint {
|
|
|
22
22
|
const days = Number(url.searchParams.get('days') || '0')
|
|
23
23
|
|
|
24
24
|
// Map collection param to slug
|
|
25
|
-
const allowedCollections
|
|
26
|
-
'email-logs': slugs.emailLogs,
|
|
27
|
-
'auth-logs': slugs.authLogs,
|
|
28
|
-
}
|
|
25
|
+
const allowedCollections = purgeableCollections(slugs)
|
|
29
26
|
|
|
30
27
|
if (!collection || !allowedCollections[collection]) {
|
|
31
28
|
return Response.json({ error: 'Invalid collection. Use email-logs or auth-logs.' }, { status: 400 })
|
|
32
29
|
}
|
|
33
30
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
? { createdAt: { less_than: cutoff } }
|
|
39
|
-
: { id: { exists: true } },
|
|
40
|
-
overrideAccess: true,
|
|
41
|
-
})
|
|
42
|
-
|
|
43
|
-
const count = Array.isArray(result.docs) ? result.docs.length : 0
|
|
31
|
+
// `days=0` wipes the journal entirely. It stays available here — an
|
|
32
|
+
// explicit, admin-only, one-off action — and is refused by the
|
|
33
|
+
// scheduled task, which must never be able to do it by accident.
|
|
34
|
+
const count = await purgeOlderThan(payload, allowedCollections[collection], days, req)
|
|
44
35
|
|
|
45
36
|
return Response.json({
|
|
46
37
|
purged: count,
|
package/src/endpoints/push.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { CollectionSlugs } from '../utils/slugs'
|
|
|
3
3
|
import { requireAdmin, handleAuthError } from '../utils/auth'
|
|
4
4
|
import { dbFind, dbCreate, dbUpdate } from '../utils/db'
|
|
5
5
|
import { getVapidPublicKey } from '../utils/push'
|
|
6
|
+
import { validatePushEndpoint, WEBHOOK_URL_MESSAGES } from '../utils/urlSafety'
|
|
6
7
|
|
|
7
8
|
/** GET /api/support/push/vapid-public-key — public key the browser uses to subscribe. */
|
|
8
9
|
export function createVapidKeyEndpoint(): Endpoint {
|
|
@@ -30,7 +31,19 @@ export function createPushSubscribeEndpoint(slugs: CollectionSlugs): Endpoint {
|
|
|
30
31
|
if (!endpoint || !p256dh || !auth) {
|
|
31
32
|
return Response.json({ error: 'subscription invalide (endpoint + keys requis).' }, { status: 400 })
|
|
32
33
|
}
|
|
33
|
-
|
|
34
|
+
// WRITE-time SSRF guard. `requireAdmin` above only proves the caller
|
|
35
|
+
// belongs to the staff collection — the very actor `utils/urlSafety`
|
|
36
|
+
// already models as hostile for webhook URLs — and `web-push` hands this
|
|
37
|
+
// string's host and port straight to `https.request`. Same guard, same
|
|
38
|
+
// reason, on the second field that steers an outbound request.
|
|
39
|
+
const endpointCheck = validatePushEndpoint(endpoint)
|
|
40
|
+
if (!endpointCheck.ok) {
|
|
41
|
+
return Response.json(
|
|
42
|
+
{ error: WEBHOOK_URL_MESSAGES[endpointCheck.reason || 'invalid_url'] },
|
|
43
|
+
{ status: 400 },
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
const data = { user: req.user!.id, endpoint, p256dh, auth, userAgent: (req.headers.get('user-agent') || '').slice(0, 256) }
|
|
34
47
|
const existing = await dbFind(req.payload, slugs.pushSubscriptions, { where: { endpoint: { equals: endpoint } }, limit: 1, depth: 0, overrideAccess: true })
|
|
35
48
|
if (existing.docs.length > 0) {
|
|
36
49
|
await dbUpdate(req.payload, slugs.pushSubscriptions, { id: (existing.docs[0] as { id: number | string }).id, data, overrideAccess: true })
|
|
@@ -5,6 +5,18 @@ import { dbFind } from '../utils/db'
|
|
|
5
5
|
/**
|
|
6
6
|
* GET /api/support/statuses
|
|
7
7
|
* Returns all ticket statuses sorted by sortOrder.
|
|
8
|
+
*
|
|
9
|
+
* The guard used to be `!!req.user`, which ANY authenticated principal
|
|
10
|
+
* satisfies — including a member of an unrelated auth collection of the host
|
|
11
|
+
* app (customers, members, subscribers created by public sign-up). Because the
|
|
12
|
+
* rows below are read with `overrideAccess: true`, that handed them the whole
|
|
13
|
+
* support workflow taxonomy — internal status names, private states, pipeline
|
|
14
|
+
* order — which `ticket-statuses.access.read` explicitly refuses them.
|
|
15
|
+
*
|
|
16
|
+
* The check below MIRRORS that ACL rather than replacing it: staff and
|
|
17
|
+
* support-clients, nobody else. `overrideAccess` stays, because the endpoint
|
|
18
|
+
* legitimately serves the full list to both, and the projection it returns is
|
|
19
|
+
* narrower than the documents themselves.
|
|
8
20
|
*/
|
|
9
21
|
export function createStatusesEndpoint(slugs: CollectionSlugs): Endpoint {
|
|
10
22
|
return {
|
|
@@ -18,6 +30,11 @@ export function createStatusesEndpoint(slugs: CollectionSlugs): Endpoint {
|
|
|
18
30
|
return Response.json({ error: 'Unauthorized' }, { status: 401 })
|
|
19
31
|
}
|
|
20
32
|
|
|
33
|
+
const collection = (req.user as { collection?: string }).collection
|
|
34
|
+
if (collection !== slugs.users && collection !== slugs.supportClients) {
|
|
35
|
+
return Response.json({ error: 'Forbidden' }, { status: 403 })
|
|
36
|
+
}
|
|
37
|
+
|
|
21
38
|
const { docs } = await dbFind(payload, slugs.ticketStatuses, {
|
|
22
39
|
sort: 'sortOrder',
|
|
23
40
|
limit: 100,
|
package/src/index.ts
CHANGED
|
@@ -28,6 +28,8 @@ export type { SupportSettings, SupportSettingsState, UserPrefs } from './utils/r
|
|
|
28
28
|
export { DEFAULT_TICKETING_FEATURES, normalizeFeatures, projectAutoClose, stripProjectedFeatures } from './utils/features'
|
|
29
29
|
export type { TicketingFeatures } from './utils/features'
|
|
30
30
|
export { MemoryRateLimitStore, PayloadRateLimitStore, RateLimiter } from './utils/rateLimiter'
|
|
31
|
+
export { DEFAULT_RETENTION, PURGE_LOGS_TASK_SLUG, purgeOlderThan, purgeableCollections, runScheduledPurge } from './utils/retention'
|
|
32
|
+
export type { RetentionConfig, ScheduledPurgeResult } from './utils/retention'
|
|
31
33
|
export type { RateLimitEntry, RateLimitStore } from './utils/rateLimiter'
|
|
32
34
|
export { DEFAULT_INBOUND_EMAIL_LIMITS, validateInboundEmailPayload, verifySecret } from './utils/webhookSecurity'
|
|
33
35
|
export type { InboundEmailInput, InboundEmailLimits, InboundEmailValidationError } from './utils/webhookSecurity'
|
package/src/plugin.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Config, Plugin, AdminViewConfig } from 'payload'
|
|
1
|
+
import type { Config, Plugin, AdminViewConfig, PayloadRequest } from 'payload'
|
|
2
2
|
import type { SupportPluginConfig, SupportFeatures } from './types'
|
|
3
3
|
import { DEFAULT_FEATURES } from './types'
|
|
4
4
|
import { resolveSlugs } from './utils/slugs'
|
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
createSupportCountersCollection,
|
|
32
32
|
} from './collections'
|
|
33
33
|
import { PayloadRateLimitStore } from './utils/rateLimiter'
|
|
34
|
+
import { DEFAULT_RETENTION, PURGE_LOGS_TASK_SLUG, runScheduledPurge } from './utils/retention'
|
|
34
35
|
import { SUPPORT_STAFF_SLUG_CONFIG_KEY } from './utils/readSettings'
|
|
35
36
|
|
|
36
37
|
function viewConfig(component: string, path: string): AdminViewConfig {
|
|
@@ -168,8 +169,57 @@ export function supportPlugin(config?: SupportPluginConfig): Plugin {
|
|
|
168
169
|
capabilities: config?.capabilities,
|
|
169
170
|
})
|
|
170
171
|
|
|
172
|
+
// ─── Retention job ───────────────────────────────────
|
|
173
|
+
//
|
|
174
|
+
// `DELETE /api/support/purge-logs` existed but nothing ever called it, so
|
|
175
|
+
// the two journals grew forever — a retention policy nobody applies is not
|
|
176
|
+
// a retention policy. This registers a daily Payload Jobs task instead of
|
|
177
|
+
// a setInterval: an interval does not survive a serverless deploy and runs
|
|
178
|
+
// N times in parallel behind N instances.
|
|
179
|
+
// The task is OPT-IN: nothing is registered unless `retention` is set.
|
|
180
|
+
//
|
|
181
|
+
// Registering it by default was tempting — a retention policy nobody opts
|
|
182
|
+
// into is how the manual purge endpoint ended up never being called. But
|
|
183
|
+
// declaring a task turns Payload's job queue on, which adds `payload-jobs`
|
|
184
|
+
// and `payload-jobs-stats` to an app that had none: two tables, i.e. a
|
|
185
|
+
// schema change imposed on every consumer of a *support* plugin, by a minor
|
|
186
|
+
// release. And it would buy them nothing, because a scheduled task only
|
|
187
|
+
// fires when the host also runs a job runner (`payload jobs:run`, a cron,
|
|
188
|
+
// or `jobs.autoRun`). Defaulting it on therefore costs two tables and
|
|
189
|
+
// delivers no purge until the integrator does the other half of the work
|
|
190
|
+
// anyway — at which point they can just as well pass `retention`.
|
|
191
|
+
//
|
|
192
|
+
// The README states the trade-off where the option is documented, so the
|
|
193
|
+
// choice is visible rather than silent. `admin-ui-pro` made the same call
|
|
194
|
+
// for the same reason.
|
|
195
|
+
const retention = config?.retention === false ? undefined : config?.retention
|
|
196
|
+
const retentionEnabled = Boolean(retention)
|
|
197
|
+
const purgeTask = {
|
|
198
|
+
slug: PURGE_LOGS_TASK_SLUG,
|
|
199
|
+
label: 'Support — purge des journaux',
|
|
200
|
+
schedule: [
|
|
201
|
+
{
|
|
202
|
+
cron: retention?.cron ?? DEFAULT_RETENTION.cron,
|
|
203
|
+
queue: retention?.queue ?? DEFAULT_RETENTION.queue,
|
|
204
|
+
},
|
|
205
|
+
],
|
|
206
|
+
handler: async ({ req }: { req: PayloadRequest }) => {
|
|
207
|
+
const result = await runScheduledPurge(req.payload, slugs, retention, req)
|
|
208
|
+
return { output: result }
|
|
209
|
+
},
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const existingJobs = incomingConfig.jobs
|
|
213
|
+
const jobs = retentionEnabled
|
|
214
|
+
? ({
|
|
215
|
+
...existingJobs,
|
|
216
|
+
tasks: [...(existingJobs?.tasks ?? []), purgeTask],
|
|
217
|
+
} as Config['jobs'])
|
|
218
|
+
: existingJobs
|
|
219
|
+
|
|
171
220
|
return {
|
|
172
221
|
...incomingConfig,
|
|
222
|
+
jobs,
|
|
173
223
|
// Publish the resolved staff collection so the server-side readers share
|
|
174
224
|
// ONE source of truth with the writers. `requireAdmin` compares against
|
|
175
225
|
// `slugs.users`; the `payload-preferences` reads used to scope themselves
|
package/src/portal/LiveChat.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
3
|
import React, { useState, useEffect, useRef, useCallback } from 'react'
|
|
4
|
-
import { MessageCircle, Send, X, Minimize2, ArrowLeft } from '
|
|
4
|
+
import { MessageCircle, Send, X, Minimize2, ArrowLeft } from './icons'
|
|
5
5
|
import type { ChatMessage, LiveChatSession, Screen } from './types'
|
|
6
6
|
import { loadSession, saveSession, clearSession } from './helpers'
|
|
7
7
|
|