@consilioweb/payload-support 2.0.1 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +670 -402
- package/dist/components/RichTextEditor/index.d.ts +19 -0
- package/dist/components/TicketConversation/RewriteDropdown.d.ts +6 -0
- package/dist/components/TicketConversation/SkeletonText.d.ts +4 -0
- package/dist/components/TicketConversation/components/AISummaryPanel.d.ts +14 -0
- package/dist/components/TicketConversation/components/ActionPanels.d.ts +47 -0
- package/dist/components/TicketConversation/components/ActivityLog.d.ts +5 -0
- package/dist/components/TicketConversation/components/ClientBar.d.ts +5 -0
- package/dist/components/TicketConversation/components/ClientHistory.d.ts +24 -0
- package/dist/components/TicketConversation/components/CodeBlock.d.ts +26 -0
- package/dist/components/TicketConversation/components/CodeBlockInserter.d.ts +12 -0
- package/dist/components/TicketConversation/components/QuickActions.d.ts +32 -0
- package/dist/components/TicketConversation/components/TicketHeader.d.ts +19 -0
- package/dist/components/TicketConversation/components/TimeTrackingPanel.d.ts +24 -0
- package/dist/components/TicketConversation/config.d.ts +10 -0
- package/dist/components/TicketConversation/config.js +1 -40
- package/dist/components/TicketConversation/constants.d.ts +56 -0
- package/dist/components/TicketConversation/context.d.ts +53 -0
- package/dist/components/TicketConversation/hooks/useAI.d.ts +21 -0
- package/dist/components/TicketConversation/hooks/useDocumentIdFromUrl.d.ts +3 -0
- package/dist/components/TicketConversation/hooks/useFeatures.d.ts +23 -0
- package/dist/components/TicketConversation/hooks/useFeatures.js +61 -0
- package/dist/components/TicketConversation/hooks/useMessageActions.d.ts +20 -0
- package/dist/components/TicketConversation/hooks/useReply.d.ts +27 -0
- package/dist/components/TicketConversation/hooks/useTicketActions.d.ts +58 -0
- package/dist/components/TicketConversation/hooks/useTimeTracking.d.ts +18 -0
- package/dist/components/TicketConversation/hooks/useTranslation.d.ts +33 -0
- package/dist/components/TicketConversation/index.d.ts +4 -0
- package/dist/components/TicketConversation/index.js +2 -2
- package/dist/components/TicketConversation/types.d.ts +56 -0
- package/dist/components/TicketConversation/utils.d.ts +2 -0
- package/dist/index.cjs +940 -404
- package/dist/index.d.cts +119 -7
- package/dist/index.d.ts +119 -7
- package/dist/index.js +936 -405
- package/dist/utils/features.d.ts +123 -0
- package/dist/utils/features.js +147 -0
- package/dist/views/BillingView/client.d.ts +2 -0
- package/dist/views/BillingView/index.d.ts +4 -0
- package/dist/views/ChatView/client.d.ts +2 -0
- package/dist/views/ChatView/client.js +9 -9
- package/dist/views/ChatView/index.d.ts +4 -0
- package/dist/views/CrmView/client.d.ts +2 -0
- package/dist/views/CrmView/client.js +6 -9
- package/dist/views/CrmView/index.d.ts +4 -0
- package/dist/views/EmailTrackingView/client.d.ts +2 -0
- package/dist/views/EmailTrackingView/index.d.ts +4 -0
- package/dist/views/ImportConversationView/client.d.ts +2 -0
- package/dist/views/ImportConversationView/index.d.ts +4 -0
- package/dist/views/LogsView/client.d.ts +2 -0
- package/dist/views/LogsView/index.d.ts +4 -0
- package/dist/views/NewTicketView/client.d.ts +2 -0
- package/dist/views/NewTicketView/index.d.ts +4 -0
- package/dist/views/PendingEmailsView/client.d.ts +2 -0
- package/dist/views/PendingEmailsView/index.d.ts +4 -0
- package/dist/views/SupportDashboardView/client.d.ts +2 -0
- package/dist/views/SupportDashboardView/index.d.ts +4 -0
- package/dist/views/TicketDetailView/NextActionItem.d.ts +6 -0
- package/dist/views/TicketDetailView/RewriteDropdown.d.ts +7 -0
- package/dist/views/TicketDetailView/client.d.ts +2 -0
- package/dist/views/TicketDetailView/client.js +2 -2
- package/dist/views/TicketDetailView/constants.d.ts +13 -0
- package/dist/views/TicketDetailView/helpers.d.ts +2 -0
- package/dist/views/TicketDetailView/index.d.ts +4 -0
- package/dist/views/TicketDetailView/types.d.ts +40 -0
- package/dist/views/TicketInboxView/client.d.ts +2 -0
- package/dist/views/TicketInboxView/client.js +5 -5
- package/dist/views/TicketInboxView/index.d.ts +4 -0
- package/dist/views/TicketingSettingsView/client.d.ts +2 -0
- package/dist/views/TicketingSettingsView/client.js +5 -4
- package/dist/views/TicketingSettingsView/index.d.ts +4 -0
- package/dist/views/TimeDashboardView/client.d.ts +2 -0
- package/dist/views/TimeDashboardView/index.d.ts +4 -0
- package/dist/views/shared/AdminViewHeader.d.ts +13 -0
- package/dist/views/shared/ErrorBoundary.d.ts +17 -0
- package/dist/views/shared/Skeleton.d.ts +21 -0
- package/dist/views/shared/StatusPill.d.ts +8 -0
- package/dist/views/shared/adminTokens.d.ts +19 -0
- package/dist/views/shared/config.d.ts +8 -0
- package/dist/views/shared/config.js +1 -40
- package/dist/views/shared/dateLocale.d.ts +5 -0
- package/dist/views/shared/index.d.ts +12 -0
- package/dist/views/shared/index.js +2 -1
- package/dist/views/shared/sla.d.ts +14 -0
- package/dist/views.d.ts +13 -13
- package/package.json +16 -31
- package/src/collections/ChatMessages.ts +59 -2
- package/src/collections/ClientSummaries.ts +10 -4
- package/src/collections/SupportClients.ts +29 -0
- package/src/collections/TicketCollaborators.ts +6 -1
- package/src/collections/TicketMessages.ts +49 -24
- package/src/collections/Tickets.ts +117 -79
- package/src/collections/TimeEntries.ts +57 -32
- package/src/collections/WebhookEndpoints.ts +44 -2
- package/src/components/TicketConversation/config.ts +16 -80
- package/src/components/TicketConversation/hooks/useFeatures.ts +109 -0
- package/src/components/TicketConversation/index.tsx +2 -2
- package/src/endpoints/admin-chat.ts +3 -3
- package/src/endpoints/ai-agent.ts +3 -3
- package/src/endpoints/ai.ts +5 -4
- package/src/endpoints/auth-2fa.ts +16 -4
- package/src/endpoints/capabilities.ts +6 -6
- package/src/endpoints/chat.ts +7 -5
- package/src/endpoints/chatbot.ts +48 -2
- package/src/endpoints/client-intelligence.ts +6 -5
- package/src/endpoints/email-stats.ts +19 -3
- package/src/endpoints/import-conversation.ts +1 -1
- package/src/endpoints/index.ts +1 -1
- package/src/endpoints/invite-collaborator.ts +29 -3
- package/src/endpoints/login.ts +15 -2
- package/src/endpoints/oauth-google.ts +130 -8
- package/src/endpoints/process-scheduled.ts +13 -8
- package/src/endpoints/resend-notification.ts +3 -3
- package/src/endpoints/round-robin-config.ts +30 -20
- package/src/endpoints/send-reminder.ts +3 -3
- package/src/endpoints/settings.ts +28 -77
- package/src/endpoints/signature.ts +9 -2
- package/src/endpoints/ticket-synthesis.ts +3 -3
- package/src/endpoints/transfer-ticket.ts +28 -3
- package/src/endpoints/typing.ts +117 -14
- package/src/endpoints/user-prefs.ts +5 -2
- package/src/index.ts +6 -2
- package/src/plugin.ts +12 -0
- package/src/portal/LiveChat.tsx +33 -22
- package/src/portal/auth/layout.tsx +19 -1
- package/src/portal/auth/tickets/detail/MessageBody.tsx +88 -0
- package/src/portal/auth/tickets/detail/TicketPolling.tsx +5 -1
- package/src/portal/auth/tickets/detail/page.tsx +2 -6
- package/src/portal/login/page.tsx +11 -3
- package/src/types/lucide-react.d.ts +10 -2
- package/src/utils/aiAgent.ts +2 -1
- package/src/utils/aiProvider.ts +21 -0
- package/src/utils/features.ts +297 -0
- package/src/utils/fireWebhooks.ts +10 -1
- package/src/utils/generateTicketSynthesis.ts +2 -1
- package/src/utils/index.ts +4 -2
- package/src/utils/rateLimiter.ts +39 -5
- package/src/utils/readSettings.ts +199 -16
- package/src/utils/ticketAccess.ts +16 -1
- package/src/utils/twoFactorChallenge.ts +80 -0
- package/src/utils/urlSafety.ts +230 -0
- package/src/utils/webhookDispatcher.ts +24 -8
- package/src/views/ChatView/client.tsx +13 -9
- package/src/views/CrmView/client.tsx +10 -11
- package/src/views/TicketDetailView/client.tsx +2 -2
- package/src/views/TicketInboxView/client.tsx +10 -5
- package/src/views/TicketingSettingsView/client.tsx +8 -6
- package/src/views/shared/config.ts +14 -80
- package/src/views/shared/index.ts +3 -1
- package/dist/components/RichTextEditor/index.cjs +0 -279
- package/dist/components/TicketConversation/RewriteDropdown.cjs +0 -98
- package/dist/components/TicketConversation/SkeletonText.cjs +0 -21
- package/dist/components/TicketConversation/components/AISummaryPanel.cjs +0 -85
- package/dist/components/TicketConversation/components/ActionPanels.cjs +0 -193
- package/dist/components/TicketConversation/components/ActivityLog.cjs +0 -24
- package/dist/components/TicketConversation/components/ClientBar.cjs +0 -43
- package/dist/components/TicketConversation/components/ClientHistory.cjs +0 -138
- package/dist/components/TicketConversation/components/CodeBlock.cjs +0 -171
- package/dist/components/TicketConversation/components/CodeBlockInserter.cjs +0 -156
- package/dist/components/TicketConversation/components/QuickActions.cjs +0 -87
- package/dist/components/TicketConversation/components/TicketHeader.cjs +0 -93
- package/dist/components/TicketConversation/components/TimeTrackingPanel.cjs +0 -134
- package/dist/components/TicketConversation/config.cjs +0 -44
- package/dist/components/TicketConversation/constants.cjs +0 -127
- package/dist/components/TicketConversation/context.cjs +0 -13
- package/dist/components/TicketConversation/hooks/useAI.cjs +0 -196
- package/dist/components/TicketConversation/hooks/useDocumentIdFromUrl.cjs +0 -20
- package/dist/components/TicketConversation/hooks/useMessageActions.cjs +0 -136
- package/dist/components/TicketConversation/hooks/useReply.cjs +0 -188
- package/dist/components/TicketConversation/hooks/useTicketActions.cjs +0 -290
- package/dist/components/TicketConversation/hooks/useTimeTracking.cjs +0 -123
- package/dist/components/TicketConversation/hooks/useTranslation.cjs +0 -101
- package/dist/components/TicketConversation/index.cjs +0 -1114
- package/dist/components/TicketConversation/types.cjs +0 -2
- package/dist/components/TicketConversation/utils.cjs +0 -27
- package/dist/views/BillingView/client.cjs +0 -469
- package/dist/views/BillingView/index.cjs +0 -34
- package/dist/views/ChatView/client.cjs +0 -301
- package/dist/views/ChatView/index.cjs +0 -34
- package/dist/views/CrmView/client.cjs +0 -458
- package/dist/views/CrmView/index.cjs +0 -34
- package/dist/views/EmailTrackingView/client.cjs +0 -171
- package/dist/views/EmailTrackingView/index.cjs +0 -34
- package/dist/views/ImportConversationView/client.cjs +0 -238
- package/dist/views/ImportConversationView/index.cjs +0 -34
- package/dist/views/LogsView/client.cjs +0 -148
- package/dist/views/LogsView/index.cjs +0 -32
- package/dist/views/NewTicketView/client.cjs +0 -213
- package/dist/views/NewTicketView/index.cjs +0 -32
- package/dist/views/PendingEmailsView/client.cjs +0 -471
- package/dist/views/PendingEmailsView/index.cjs +0 -34
- package/dist/views/SupportDashboardView/client.cjs +0 -437
- package/dist/views/SupportDashboardView/index.cjs +0 -34
- package/dist/views/TicketDetailView/NextActionItem.cjs +0 -40
- package/dist/views/TicketDetailView/RewriteDropdown.cjs +0 -80
- package/dist/views/TicketDetailView/client.cjs +0 -1412
- package/dist/views/TicketDetailView/constants.cjs +0 -20
- package/dist/views/TicketDetailView/helpers.cjs +0 -23
- package/dist/views/TicketDetailView/index.cjs +0 -34
- package/dist/views/TicketDetailView/types.cjs +0 -2
- package/dist/views/TicketInboxView/client.cjs +0 -359
- package/dist/views/TicketInboxView/index.cjs +0 -32
- package/dist/views/TicketingSettingsView/client.cjs +0 -740
- package/dist/views/TicketingSettingsView/index.cjs +0 -34
- package/dist/views/TimeDashboardView/client.cjs +0 -180
- package/dist/views/TimeDashboardView/index.cjs +0 -34
- package/dist/views/shared/AdminViewHeader.cjs +0 -68
- package/dist/views/shared/ErrorBoundary.cjs +0 -55
- package/dist/views/shared/Skeleton.cjs +0 -77
- package/dist/views/shared/StatusPill.cjs +0 -47
- package/dist/views/shared/adminTokens.cjs +0 -34
- package/dist/views/shared/config.cjs +0 -44
- package/dist/views/shared/dateLocale.cjs +0 -5
- package/dist/views/shared/index.cjs +0 -72
- package/dist/views/shared/sla.cjs +0 -37
- package/dist/views.cjs +0 -86
- package/dist/views.d.cts +0 -13
- package/src/__tests__/aiSummaryRendering.test.ts +0 -21
- package/src/__tests__/authResponses.test.ts +0 -69
- package/src/__tests__/capabilities.test.ts +0 -71
- package/src/__tests__/generateTrackingToken.test.ts +0 -67
- package/src/__tests__/integration/ai-agent.test.ts +0 -46
- package/src/__tests__/integration/auth.test.ts +0 -43
- package/src/__tests__/integration/automation-rules.test.ts +0 -68
- package/src/__tests__/integration/buildTestPayload.ts +0 -52
- package/src/__tests__/integration/channels.test.ts +0 -52
- package/src/__tests__/integration/core-behaviors.test.ts +0 -81
- package/src/__tests__/integration/dashboard-volume.test.ts +0 -31
- package/src/__tests__/integration/digest.test.ts +0 -48
- package/src/__tests__/integration/invoice-pdf.test.ts +0 -30
- package/src/__tests__/integration/isolation.test.ts +0 -81
- package/src/__tests__/integration/mentions-invoice.test.ts +0 -50
- package/src/__tests__/integration/nps.test.ts +0 -37
- package/src/__tests__/integration/per-team-sla.test.ts +0 -44
- package/src/__tests__/integration/push.test.ts +0 -75
- package/src/__tests__/integration/sanitization.test.ts +0 -35
- package/src/__tests__/integration/sla-pause.test.ts +0 -39
- package/src/__tests__/integration/smoke.test.ts +0 -26
- package/src/__tests__/integration/snooze.test.ts +0 -68
- package/src/__tests__/integration/teams.test.ts +0 -38
- package/src/__tests__/rateLimiter.test.ts +0 -83
- package/src/__tests__/sanitizeHtml.test.ts +0 -165
- package/src/__tests__/sla.test.ts +0 -258
- package/src/__tests__/trackOpenEndpoint.test.ts +0 -54
- package/src/__tests__/translations.test.ts +0 -67
- package/src/__tests__/webhookSecurity.test.ts +0 -58
|
@@ -13,11 +13,24 @@ import { dbFind } from './db'
|
|
|
13
13
|
*
|
|
14
14
|
* Returns an empty array when the client has no accessible tickets — callers MUST
|
|
15
15
|
* treat that as "deny" (e.g. filter on a sentinel id) rather than "allow all".
|
|
16
|
+
*
|
|
17
|
+
* `mode` selects which of the two scopes is wanted:
|
|
18
|
+
* - `'read'` (default) — owned tickets + every ticket collaborated on, whatever
|
|
19
|
+
* the role. This is the historical behaviour and what `access.read` needs.
|
|
20
|
+
* - `'write'` — owned tickets + collaborated tickets whose row carries
|
|
21
|
+
* `role === 'collaborator'`. The invitation endpoint offers two roles and the
|
|
22
|
+
* invitation email promises "lecteur (consultation)" for `viewer`, but nothing
|
|
23
|
+
* ever read the field back: a viewer could post into the thread and trigger the
|
|
24
|
+
* whole notification chain. Rows with no explicit role are treated as viewers
|
|
25
|
+
* (the collection's own `defaultValue`), i.e. read-only — fail closed.
|
|
16
26
|
*/
|
|
27
|
+
export type TicketAccessMode = 'read' | 'write'
|
|
28
|
+
|
|
17
29
|
export async function resolveAccessibleTicketIds(
|
|
18
30
|
payload: Payload,
|
|
19
31
|
slugs: CollectionSlugs,
|
|
20
32
|
clientId: number | string,
|
|
33
|
+
mode: TicketAccessMode = 'read',
|
|
21
34
|
): Promise<Array<number | string>> {
|
|
22
35
|
const ids = new Set<number | string>()
|
|
23
36
|
|
|
@@ -41,7 +54,9 @@ export async function resolveAccessibleTicketIds(
|
|
|
41
54
|
overrideAccess: true,
|
|
42
55
|
})
|
|
43
56
|
for (const r of collab.docs) {
|
|
44
|
-
const row = r as { ticket?: number | string | { id?: number | string } }
|
|
57
|
+
const row = r as { ticket?: number | string | { id?: number | string }; role?: string }
|
|
58
|
+
// `viewer` (and any row without an explicit role) grants read only.
|
|
59
|
+
if (mode === 'write' && row.role !== 'collaborator') continue
|
|
45
60
|
const tid = typeof row.ticket === 'object' ? row.ticket?.id : row.ticket
|
|
46
61
|
if (tid !== undefined && tid !== null) ids.add(tid)
|
|
47
62
|
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from 'crypto'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Proof that the password step already succeeded for this email address.
|
|
5
|
+
*
|
|
6
|
+
* `POST /support/2fa {action:'send'}` used to accept an email and nothing else.
|
|
7
|
+
* Its only guard was a limiter keyed on that email — 3 sends per hour — so an
|
|
8
|
+
* ANONYMOUS caller who knew a victim's address (they circulate in ticket threads
|
|
9
|
+
* and email copies) could burn the quota in three requests and leave the victim
|
|
10
|
+
* unable to obtain the code their own login now demands. Since the OAuth path
|
|
11
|
+
* enforces 2FA as well, that was a complete, renewable account lockout, plus an
|
|
12
|
+
* outbound-email amplifier and a way to overwrite a code already in flight.
|
|
13
|
+
*
|
|
14
|
+
* The fix keys the consumable resource to something the ATTACKER cannot produce:
|
|
15
|
+
* a short-lived signature issued only by a successful password (or Google)
|
|
16
|
+
* authentication. Same HMAC-over-PAYLOAD_SECRET construction as the tracking
|
|
17
|
+
* pixel, no storage, no new dependency.
|
|
18
|
+
*
|
|
19
|
+
* Format: `<expiry-ms>.<hex hmac>`. The signature covers the email AND the
|
|
20
|
+
* expiry, so neither can be swapped without invalidating the token, and a token
|
|
21
|
+
* issued for one account is useless for another.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** Matches the 10-minute validity of the code the challenge lets you request. */
|
|
25
|
+
export const TWO_FACTOR_CHALLENGE_TTL_MS = 10 * 60 * 1000
|
|
26
|
+
|
|
27
|
+
function challengeSecret(): string {
|
|
28
|
+
const secret = process.env.PAYLOAD_SECRET
|
|
29
|
+
if (!secret) {
|
|
30
|
+
// Fail closed: the codes themselves are hashed with this secret, so 2FA is
|
|
31
|
+
// already inoperable without it — never fall back to a source-visible value.
|
|
32
|
+
throw new Error(
|
|
33
|
+
'[support][2fa] PAYLOAD_SECRET is not set — refusing to issue a 2FA challenge with an insecure fallback secret',
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
return secret
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function normalizeEmail(email: string): string {
|
|
40
|
+
return String(email).trim().toLowerCase()
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function sign(email: string, expiresAt: number): string {
|
|
44
|
+
return createHmac('sha256', challengeSecret())
|
|
45
|
+
.update(`2fa-challenge:${normalizeEmail(email)}:${expiresAt}`)
|
|
46
|
+
.digest('hex')
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Issued by the login endpoints once credentials have been verified. */
|
|
50
|
+
export function issueTwoFactorChallenge(email: string, now: number = Date.now()): string {
|
|
51
|
+
const expiresAt = now + TWO_FACTOR_CHALLENGE_TTL_MS
|
|
52
|
+
return `${expiresAt}.${sign(email, expiresAt)}`
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Constant-time; false on any malformed, expired or foreign token. */
|
|
56
|
+
export function verifyTwoFactorChallenge(
|
|
57
|
+
email: unknown,
|
|
58
|
+
token: unknown,
|
|
59
|
+
now: number = Date.now(),
|
|
60
|
+
): boolean {
|
|
61
|
+
if (typeof email !== 'string' || !email || typeof token !== 'string') return false
|
|
62
|
+
const separator = token.indexOf('.')
|
|
63
|
+
if (separator <= 0) return false
|
|
64
|
+
|
|
65
|
+
const expiresAt = Number(token.slice(0, separator))
|
|
66
|
+
if (!Number.isSafeInteger(expiresAt) || expiresAt <= now) return false
|
|
67
|
+
|
|
68
|
+
const received = token.slice(separator + 1)
|
|
69
|
+
if (!/^[0-9a-f]{64}$/i.test(received)) return false
|
|
70
|
+
|
|
71
|
+
let expected: string
|
|
72
|
+
try {
|
|
73
|
+
expected = sign(email, expiresAt)
|
|
74
|
+
} catch {
|
|
75
|
+
return false // no secret configured — fail closed
|
|
76
|
+
}
|
|
77
|
+
const a = Buffer.from(expected, 'hex')
|
|
78
|
+
const b = Buffer.from(received.toLowerCase(), 'hex')
|
|
79
|
+
return a.length === b.length && timingSafeEqual(a, b)
|
|
80
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { lookup } from 'dns/promises'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* SSRF guards for every URL the SERVER follows on behalf of a user-supplied
|
|
5
|
+
* value (today: outbound webhook endpoints).
|
|
6
|
+
*
|
|
7
|
+
* `webhook-endpoints.url` is a plain `text` field with no validation, writable by
|
|
8
|
+
* any member of the staff collection, and `_sendToEndpoint` used to `fetch()` it
|
|
9
|
+
* with the default redirect policy. Pointing it at `http://169.254.169.254/…` or
|
|
10
|
+
* an internal service turned every ticket event into a blind SSRF, with the HTTP
|
|
11
|
+
* status echoed back into the admin through `lastStatus`.
|
|
12
|
+
*
|
|
13
|
+
* Three layers, because any one of them alone is bypassable:
|
|
14
|
+
* 1. `validateWebhookUrl` at WRITE time — rejects the scheme and the literal
|
|
15
|
+
* private / loopback / link-local hosts before the row is saved.
|
|
16
|
+
* 2. `assertPublicHost` at SEND time — resolves the name and re-checks the
|
|
17
|
+
* resolved addresses, so a DNS record flipped to 127.0.0.1 after the row was
|
|
18
|
+
* validated is caught. It FAILS CLOSED: a resolution that errors, returns
|
|
19
|
+
* nothing, or outruns `LOOKUP_TIMEOUT_MS` is treated as "do not connect".
|
|
20
|
+
* Note the limit, honestly stated: `fetch` resolves the name a SECOND time,
|
|
21
|
+
* so this narrows the rebinding window rather than closing it — closing it
|
|
22
|
+
* would mean connecting to the address we validated (a custom dispatcher
|
|
23
|
+
* lookup), which is a dependency this plugin does not take.
|
|
24
|
+
* 3. `safeFetch` follows redirects MANUALLY, re-running (1) and (2) on every
|
|
25
|
+
* hop: a public host answering `302 Location: http://127.0.0.1:8080/` would
|
|
26
|
+
* otherwise walk straight through the first two layers.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/** Set `SUPPORT_ALLOW_INSECURE_WEBHOOKS=1` to allow plain http (local dev only). */
|
|
30
|
+
function httpAllowed(): boolean {
|
|
31
|
+
return process.env.SUPPORT_ALLOW_INSECURE_WEBHOOKS === '1'
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const BLOCKED_HOST_SUFFIXES = ['.local', '.localhost', '.internal', '.home.arpa']
|
|
35
|
+
|
|
36
|
+
/** Dotted-quad only — no octal/hex shorthand, which `new URL()` normalises away anyway. */
|
|
37
|
+
function parseIPv4(host: string): number[] | null {
|
|
38
|
+
const parts = host.split('.')
|
|
39
|
+
if (parts.length !== 4) return null
|
|
40
|
+
const octets: number[] = []
|
|
41
|
+
for (const part of parts) {
|
|
42
|
+
if (!/^\d{1,3}$/.test(part)) return null
|
|
43
|
+
const n = Number(part)
|
|
44
|
+
if (n > 255) return null
|
|
45
|
+
octets.push(n)
|
|
46
|
+
}
|
|
47
|
+
return octets
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function isPrivateIPv4(octets: number[]): boolean {
|
|
51
|
+
const [a, b] = octets
|
|
52
|
+
if (a === 0) return true // "this network" / 0.0.0.0
|
|
53
|
+
if (a === 10) return true // 10/8
|
|
54
|
+
if (a === 127) return true // loopback
|
|
55
|
+
if (a === 169 && b === 254) return true // link-local, incl. cloud metadata
|
|
56
|
+
if (a === 172 && b >= 16 && b <= 31) return true // 172.16/12
|
|
57
|
+
if (a === 192 && b === 168) return true // 192.168/16
|
|
58
|
+
if (a === 192 && b === 0) return true // 192.0.0/24 + 192.0.2/24
|
|
59
|
+
if (a === 198 && (b === 18 || b === 19)) return true // benchmarking
|
|
60
|
+
if (a === 100 && b >= 64 && b <= 127) return true // CGNAT 100.64/10
|
|
61
|
+
if (a >= 224) return true // multicast + reserved + broadcast
|
|
62
|
+
return false
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Handles the IPv4-mapped and IPv4-compatible forms — `::ffff:10.0.0.1` and
|
|
67
|
+
* `::ffff:a00:1` both reach 10.0.0.1 and both must be blocked.
|
|
68
|
+
*/
|
|
69
|
+
function isPrivateIPv6(host: string): boolean {
|
|
70
|
+
const lower = host.toLowerCase()
|
|
71
|
+
if (lower === '::' || lower === '::1') return true
|
|
72
|
+
if (lower.startsWith('fe8') || lower.startsWith('fe9') || lower.startsWith('fea') || lower.startsWith('feb')) return true // fe80::/10
|
|
73
|
+
if (lower.startsWith('fc') || lower.startsWith('fd')) return true // fc00::/7 unique-local
|
|
74
|
+
if (lower.startsWith('ff')) return true // multicast
|
|
75
|
+
|
|
76
|
+
// IPv4-mapped / -compatible, dotted form: ::ffff:10.0.0.1
|
|
77
|
+
const dotted = lower.match(/^::(?:ffff:)?(\d{1,3}(?:\.\d{1,3}){3})$/)
|
|
78
|
+
if (dotted) {
|
|
79
|
+
const octets = parseIPv4(dotted[1])
|
|
80
|
+
return octets ? isPrivateIPv4(octets) : true
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// IPv4-mapped, hex form: ::ffff:a00:1
|
|
84
|
+
const hex = lower.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/)
|
|
85
|
+
if (hex) {
|
|
86
|
+
const high = parseInt(hex[1], 16)
|
|
87
|
+
const low = parseInt(hex[2], 16)
|
|
88
|
+
return isPrivateIPv4([high >> 8, high & 0xff, low >> 8, low & 0xff])
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return false
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Strips the brackets WHATWG URL keeps around an IPv6 literal. */
|
|
95
|
+
function normalizeHost(hostname: string): string {
|
|
96
|
+
const host = hostname.trim().toLowerCase()
|
|
97
|
+
return host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : host
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* True when the host is a literal address (or a name) that must never be
|
|
102
|
+
* reached from the server. Names that are not literals are NOT blocked here —
|
|
103
|
+
* they are resolved and re-checked by `assertPublicHost`.
|
|
104
|
+
*/
|
|
105
|
+
export function isBlockedHost(hostname: string): boolean {
|
|
106
|
+
const host = normalizeHost(hostname)
|
|
107
|
+
if (!host) return true
|
|
108
|
+
if (host === 'localhost') return true
|
|
109
|
+
if (BLOCKED_HOST_SUFFIXES.some((suffix) => host.endsWith(suffix))) return true
|
|
110
|
+
|
|
111
|
+
const v4 = parseIPv4(host)
|
|
112
|
+
if (v4) return isPrivateIPv4(v4)
|
|
113
|
+
if (host.includes(':')) return isPrivateIPv6(host)
|
|
114
|
+
return false
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export interface UrlValidationResult {
|
|
118
|
+
ok: boolean
|
|
119
|
+
/** Machine-readable reason, also used as the field validation message. */
|
|
120
|
+
reason?: 'invalid_url' | 'scheme_not_allowed' | 'private_host'
|
|
121
|
+
url?: URL
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Scheme + literal-host validation. Cheap, synchronous, safe to run in a field `validate`. */
|
|
125
|
+
export function validateWebhookUrl(raw: unknown): UrlValidationResult {
|
|
126
|
+
if (typeof raw !== 'string' || !raw.trim()) return { ok: false, reason: 'invalid_url' }
|
|
127
|
+
let url: URL
|
|
128
|
+
try {
|
|
129
|
+
url = new URL(raw.trim())
|
|
130
|
+
} catch {
|
|
131
|
+
return { ok: false, reason: 'invalid_url' }
|
|
132
|
+
}
|
|
133
|
+
if (url.protocol !== 'https:' && !(url.protocol === 'http:' && httpAllowed())) {
|
|
134
|
+
return { ok: false, reason: 'scheme_not_allowed' }
|
|
135
|
+
}
|
|
136
|
+
if (isBlockedHost(url.hostname)) return { ok: false, reason: 'private_host' }
|
|
137
|
+
return { ok: true, url }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** French messages for the collection field — mirrors the rest of the admin UI. */
|
|
141
|
+
export const WEBHOOK_URL_MESSAGES: Record<NonNullable<UrlValidationResult['reason']>, string> = {
|
|
142
|
+
invalid_url: 'URL invalide.',
|
|
143
|
+
scheme_not_allowed: 'Seules les URL https:// sont acceptées.',
|
|
144
|
+
private_host: 'Les adresses privées, loopback et link-local sont interdites (SSRF).',
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Resolve the name and reject unless EVERY resolved address is public.
|
|
149
|
+
*
|
|
150
|
+
* FAIL-CLOSED on anything that is not a clean answer. The previous version
|
|
151
|
+
* returned `true` on its own timeout, on an empty answer and on every error, on
|
|
152
|
+
* the theory that "`fetch` would fail the same way". A slow resolver is not a
|
|
153
|
+
* failing resolver: an attacker-run authoritative server answering just after
|
|
154
|
+
* the deadline got this guard to wave the request through, and `fetch` then did
|
|
155
|
+
* its OWN resolution — no deadline — and reached 127.0.0.1. So a timeout, a
|
|
156
|
+
* temporary resolver error (EAI_AGAIN/EAI_FAIL) and an empty answer all block.
|
|
157
|
+
*
|
|
158
|
+
* The ONE tolerated case is a definitive "this name does not exist"
|
|
159
|
+
* (ENOTFOUND / ENODATA): there is no address for `fetch` to reach either, so
|
|
160
|
+
* blocking would only swap one failure message for another.
|
|
161
|
+
*
|
|
162
|
+
* The deadline is generous on purpose — with fail-closed semantics it now costs
|
|
163
|
+
* a legitimate delivery when it fires, and a resolver needing more than 5s is
|
|
164
|
+
* broken, not slow. It still bounds the delivery: the `fetch` that follows has
|
|
165
|
+
* its own 10s abort.
|
|
166
|
+
*/
|
|
167
|
+
const LOOKUP_TIMEOUT_MS = 5000
|
|
168
|
+
const LOOKUP_TIMED_OUT = Symbol('lookup-timed-out')
|
|
169
|
+
/** Definitive "no such host" — nothing for `fetch` to connect to either. */
|
|
170
|
+
const NONEXISTENT_HOST_CODES = new Set(['ENOTFOUND', 'ENODATA', 'NOTFOUND'])
|
|
171
|
+
|
|
172
|
+
export async function assertPublicHost(hostname: string): Promise<boolean> {
|
|
173
|
+
const host = normalizeHost(hostname)
|
|
174
|
+
if (isBlockedHost(host)) return false
|
|
175
|
+
if (parseIPv4(host) || host.includes(':')) return true // already a literal, checked above
|
|
176
|
+
try {
|
|
177
|
+
// Bounded: a stalled resolver must not hold a webhook delivery open.
|
|
178
|
+
const addresses = await Promise.race([
|
|
179
|
+
lookup(host, { all: true }),
|
|
180
|
+
new Promise<typeof LOOKUP_TIMED_OUT>((resolve) =>
|
|
181
|
+
setTimeout(() => resolve(LOOKUP_TIMED_OUT), LOOKUP_TIMEOUT_MS).unref?.(),
|
|
182
|
+
),
|
|
183
|
+
])
|
|
184
|
+
if (addresses === LOOKUP_TIMED_OUT) return false
|
|
185
|
+
// `[].every()` is `true`: an empty answer must not read as "all public".
|
|
186
|
+
if (!Array.isArray(addresses) || addresses.length === 0) return false
|
|
187
|
+
return addresses.every((entry) => !isBlockedHost(entry.address))
|
|
188
|
+
} catch (error) {
|
|
189
|
+
const code = (error as { code?: string } | null)?.code
|
|
190
|
+
return typeof code === 'string' && NONEXISTENT_HOST_CODES.has(code)
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export class BlockedRequestError extends Error {
|
|
195
|
+
constructor(public readonly reason: NonNullable<UrlValidationResult['reason']> | 'too_many_redirects') {
|
|
196
|
+
super(`Blocked outbound request: ${reason}`)
|
|
197
|
+
this.name = 'BlockedRequestError'
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const MAX_REDIRECTS = 3
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* `fetch` with SSRF guards on the initial URL AND on every redirect target.
|
|
205
|
+
*
|
|
206
|
+
* `redirect: 'manual'` is the point: the default policy follows 3xx inside
|
|
207
|
+
* undici, where no guard of ours can see the new target.
|
|
208
|
+
*/
|
|
209
|
+
export async function safeFetch(rawUrl: string, init: RequestInit): Promise<Response> {
|
|
210
|
+
let current = rawUrl
|
|
211
|
+
let body: BodyInit | null | undefined = init.body
|
|
212
|
+
|
|
213
|
+
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
|
|
214
|
+
const validation = validateWebhookUrl(current)
|
|
215
|
+
if (!validation.ok || !validation.url) throw new BlockedRequestError(validation.reason || 'invalid_url')
|
|
216
|
+
if (!(await assertPublicHost(validation.url.hostname))) throw new BlockedRequestError('private_host')
|
|
217
|
+
|
|
218
|
+
const response = await fetch(current, { ...init, body, redirect: 'manual' })
|
|
219
|
+
if (response.status < 300 || response.status > 399) return response
|
|
220
|
+
|
|
221
|
+
const location = response.headers.get('location')
|
|
222
|
+
if (!location) return response
|
|
223
|
+
|
|
224
|
+
current = new URL(location, current).toString()
|
|
225
|
+
// 303 (and, in practice, 301/302 on a POST) turns the follow-up into a GET.
|
|
226
|
+
if (response.status === 303) body = undefined
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
throw new BlockedRequestError('too_many_redirects')
|
|
230
|
+
}
|
|
@@ -2,8 +2,18 @@ import crypto from 'crypto'
|
|
|
2
2
|
import type { BasePayload } from 'payload'
|
|
3
3
|
import type { CollectionSlugs } from './slugs'
|
|
4
4
|
import { dbFind, dbUpdate } from './db'
|
|
5
|
+
import { safeFetch } from './urlSafety'
|
|
5
6
|
|
|
6
|
-
|
|
7
|
+
/**
|
|
8
|
+
* Outbound webhook events. Must stay in sync with the `events` options of the
|
|
9
|
+
* webhook-endpoints collection.
|
|
10
|
+
*/
|
|
11
|
+
export type WebhookEvent =
|
|
12
|
+
| 'ticket_created'
|
|
13
|
+
| 'ticket_resolved'
|
|
14
|
+
| 'ticket_replied'
|
|
15
|
+
| 'ticket_assigned'
|
|
16
|
+
| 'sla_breached'
|
|
7
17
|
|
|
8
18
|
/**
|
|
9
19
|
* Dispatch outbound webhooks for a given event.
|
|
@@ -21,9 +31,12 @@ export function dispatchWebhook(
|
|
|
21
31
|
event: WebhookEvent,
|
|
22
32
|
payload: BasePayload,
|
|
23
33
|
slugs: CollectionSlugs,
|
|
24
|
-
): void {
|
|
25
|
-
// Fire and forget
|
|
26
|
-
|
|
34
|
+
): Promise<void> {
|
|
35
|
+
// Fire and forget for callers (hooks never await it); the promise is returned so
|
|
36
|
+
// tests — and any caller that wants to — can wait for delivery to settle.
|
|
37
|
+
const done = _dispatch(data, event, payload, slugs)
|
|
38
|
+
void done
|
|
39
|
+
return done
|
|
27
40
|
}
|
|
28
41
|
|
|
29
42
|
async function _dispatch(
|
|
@@ -49,9 +62,9 @@ async function _dispatch(
|
|
|
49
62
|
|
|
50
63
|
const body = JSON.stringify({ event, data, timestamp: new Date().toISOString() })
|
|
51
64
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
65
|
+
await Promise.allSettled(
|
|
66
|
+
endpoints.map((endpoint) => _sendToEndpoint(endpoint as any, body, payload, slugs)),
|
|
67
|
+
)
|
|
55
68
|
} catch (err) {
|
|
56
69
|
console.error(`[webhook] Failed to fetch endpoints for event ${event}:`, err)
|
|
57
70
|
}
|
|
@@ -78,7 +91,10 @@ async function _sendToEndpoint(
|
|
|
78
91
|
headers['X-Webhook-Signature'] = signature
|
|
79
92
|
}
|
|
80
93
|
|
|
81
|
-
|
|
94
|
+
// safeFetch: rejects non-https and private / loopback / link-local targets,
|
|
95
|
+
// re-resolves the name before connecting (DNS rebinding) and re-checks every
|
|
96
|
+
// redirect hop instead of letting undici follow a 302 into the private range.
|
|
97
|
+
const response = await safeFetch(endpoint.url, {
|
|
82
98
|
method: 'POST',
|
|
83
99
|
headers,
|
|
84
100
|
body,
|
|
@@ -40,6 +40,11 @@ export const ChatViewClient: React.FC = () => {
|
|
|
40
40
|
const sessionsPollTimeout = useRef<NodeJS.Timeout>(undefined)
|
|
41
41
|
const messagesPollInterval = useRef(3000)
|
|
42
42
|
const messagesPollTimeout = useRef<NodeJS.Timeout>(undefined)
|
|
43
|
+
// Serialized snapshot of the last sessions payload. The change detection MUST
|
|
44
|
+
// live outside the setSessions updater: React skips the updater entirely on the
|
|
45
|
+
// eager-state path, so a flag assigned inside it stops being written after the
|
|
46
|
+
// first render and the adaptive backoff then climbs to its ceiling and stays there.
|
|
47
|
+
const sessionsSnapshotRef = useRef<string>('')
|
|
43
48
|
|
|
44
49
|
// Fetch sessions list
|
|
45
50
|
const [sessionExpired, setSessionExpired] = useState(false)
|
|
@@ -50,15 +55,14 @@ export const ChatViewClient: React.FC = () => {
|
|
|
50
55
|
if (res.status === 401 || res.status === 403) { setSessionExpired(true); return }
|
|
51
56
|
if (res.ok) {
|
|
52
57
|
const data = await res.json()
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
})
|
|
58
|
+
const newActive = data.active || []
|
|
59
|
+
const newClosed = data.closed || []
|
|
60
|
+
const snapshot = JSON.stringify([newActive, newClosed])
|
|
61
|
+
if (snapshot !== sessionsSnapshotRef.current) {
|
|
62
|
+
sessionsSnapshotRef.current = snapshot
|
|
63
|
+
hadChanges = true
|
|
64
|
+
setSessions({ active: newActive, closed: newClosed })
|
|
65
|
+
}
|
|
62
66
|
}
|
|
63
67
|
} catch { /* ignore */ }
|
|
64
68
|
setLoading(false)
|
|
@@ -144,26 +144,25 @@ export const CrmClient: React.FC = () => {
|
|
|
144
144
|
const fetchDetail = useCallback(async (clientId: number) => {
|
|
145
145
|
setDetailLoading(true)
|
|
146
146
|
try {
|
|
147
|
-
const [clientRes, ticketsRes, projectsRes
|
|
147
|
+
const [clientRes, ticketsRes, projectsRes] = await Promise.all([
|
|
148
148
|
fetch(`/api/support-clients/${clientId}?depth=0`),
|
|
149
149
|
fetch(`/api/tickets?where[client][equals]=${clientId}&sort=-createdAt&limit=20&depth=0`),
|
|
150
150
|
fetch(`/api/projects?where[client][equals]=${clientId}&depth=0`),
|
|
151
|
-
fetch(`/api/time-entries?limit=0&depth=0`),
|
|
152
151
|
])
|
|
153
152
|
|
|
154
153
|
const client = clientRes.ok ? await clientRes.json() : null
|
|
155
154
|
const tickets = ticketsRes.ok ? (await ticketsRes.json()).docs || [] : []
|
|
156
155
|
const projects = projectsRes.ok ? (await projectsRes.json()).docs || [] : []
|
|
157
156
|
|
|
158
|
-
//
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
157
|
+
// Sum the per-ticket rollup maintained by the TimeEntries afterChange /
|
|
158
|
+
// afterDelete hooks. This used to download the ENTIRE time-entries table
|
|
159
|
+
// (via limit=0, which does not mean "no document") only to keep the rows whose
|
|
160
|
+
// ticket is one of the 20 listed here — same result, O(1) payload instead of
|
|
161
|
+
// O(all time entries ever recorded).
|
|
162
|
+
const totalTimeMinutes = tickets.reduce(
|
|
163
|
+
(sum: number, t: { totalTimeMinutes?: number }) => sum + (t.totalTimeMinutes || 0),
|
|
164
|
+
0,
|
|
165
|
+
)
|
|
167
166
|
|
|
168
167
|
const openTickets = tickets.filter((t: { status: string }) =>
|
|
169
168
|
['open', 'waiting_client'].includes(t.status),
|
|
@@ -6,7 +6,7 @@ import Link from 'next/link'
|
|
|
6
6
|
import { RichTextEditor, type RichTextEditorHandle } from '../../components/RichTextEditor/index'
|
|
7
7
|
import { hasCodeBlocks, MessageWithCodeBlocks, CodeBlockRendererHtml } from '../../components/TicketConversation/components/CodeBlock'
|
|
8
8
|
import { CodeBlockInserter } from '../../components/TicketConversation/components/CodeBlockInserter'
|
|
9
|
-
import {
|
|
9
|
+
import { useFeatures } from '../../components/TicketConversation/hooks/useFeatures'
|
|
10
10
|
import { computeSlaState, formatSlaRemaining } from '../shared/sla'
|
|
11
11
|
import { useTranslation } from '../../components/TicketConversation/hooks/useTranslation'
|
|
12
12
|
import { DATE_LOCALE } from '../shared/dateLocale'
|
|
@@ -21,7 +21,7 @@ export const TicketDetailClient: React.FC = () => {
|
|
|
21
21
|
const { t } = useTranslation()
|
|
22
22
|
const searchParams = useSearchParams()
|
|
23
23
|
const ticketId = searchParams.get('id')
|
|
24
|
-
const features =
|
|
24
|
+
const { features } = useFeatures()
|
|
25
25
|
const editorRef = useRef<RichTextEditorHandle>(null)
|
|
26
26
|
const threadEndRef = useRef<HTMLDivElement>(null)
|
|
27
27
|
const dropdownRef = useRef<HTMLDivElement>(null)
|
|
@@ -165,12 +165,17 @@ export const TicketInboxClient: React.FC = () => {
|
|
|
165
165
|
try {
|
|
166
166
|
// Exclude snoozed tickets from the counts too (mirrors the list query).
|
|
167
167
|
const sn = `where[and][0][or][0][snoozeUntil][exists]=false&where[and][0][or][1][snoozeUntil][less_than_equal]=${encodeURIComponent(new Date().toISOString())}`
|
|
168
|
+
// `/count` is a real SQL COUNT. The previous limit=0 idiom did NOT mean
|
|
169
|
+
// "no document": Payload disables pagination for limit=0, skips the count
|
|
170
|
+
// query and derives totalDocs from the rows it just materialised — so each
|
|
171
|
+
// of these five counters used to read the whole tickets table (relations
|
|
172
|
+
// included) just to display a badge.
|
|
168
173
|
const [all, openRes, waiting, resolved, breach] = await Promise.all([
|
|
169
|
-
fetch(`/api/tickets
|
|
170
|
-
fetch(`/api/tickets?
|
|
171
|
-
fetch(`/api/tickets?
|
|
172
|
-
fetch(`/api/tickets?
|
|
173
|
-
fetch(`/api/tickets?
|
|
174
|
+
fetch(`/api/tickets/count?${sn}`, { credentials: 'include' }),
|
|
175
|
+
fetch(`/api/tickets/count?where[status][equals]=open&${sn}`, { credentials: 'include' }),
|
|
176
|
+
fetch(`/api/tickets/count?where[status][equals]=waiting_client&${sn}`, { credentials: 'include' }),
|
|
177
|
+
fetch(`/api/tickets/count?where[status][equals]=resolved&${sn}`, { credentials: 'include' }),
|
|
178
|
+
fetch(`/api/tickets/count?where[or][0][slaFirstResponseBreached][equals]=true&where[or][1][slaResolutionBreached][equals]=true&${sn}`, { credentials: 'include' }),
|
|
174
179
|
])
|
|
175
180
|
const [a, o, w, r, b] = await Promise.all([all.json(), openRes.json(), waiting.json(), resolved.json(), breach.json()])
|
|
176
181
|
setCounts({
|
|
@@ -4,7 +4,8 @@ import React, { useState, useEffect } from 'react'
|
|
|
4
4
|
import { Settings, Mail, Bot, Clock, Timer, Globe, FileSignature } from 'lucide-react'
|
|
5
5
|
import { V, btnStyle } from '../shared/adminTokens'
|
|
6
6
|
import { AdminViewHeader } from '../shared/AdminViewHeader'
|
|
7
|
-
import {
|
|
7
|
+
import { DEFAULT_FEATURES, type TicketingFeatures } from '../../components/TicketConversation/config'
|
|
8
|
+
import { useFeatures } from '../../components/TicketConversation/hooks/useFeatures'
|
|
8
9
|
import { useTranslation } from '../../components/TicketConversation/hooks/useTranslation'
|
|
9
10
|
import ts from '../../styles/TicketingSettings.module.scss'
|
|
10
11
|
|
|
@@ -301,7 +302,7 @@ export const TicketingSettingsClient: React.FC = () => {
|
|
|
301
302
|
const { t } = useTranslation()
|
|
302
303
|
const FEATURE_LIST = React.useMemo(() => getFeatureList(t), [t])
|
|
303
304
|
const CATEGORIES = React.useMemo(() => getCategories(t), [t])
|
|
304
|
-
const
|
|
305
|
+
const { features, setFeatures, save: saveFeatures } = useFeatures()
|
|
305
306
|
const [settings, setSettings] = useState<AllSettings>(DEFAULT_SETTINGS)
|
|
306
307
|
const [signature, setSignature] = useState('')
|
|
307
308
|
const [saved, setSaved] = useState(false)
|
|
@@ -355,15 +356,16 @@ export const TicketingSettingsClient: React.FC = () => {
|
|
|
355
356
|
|
|
356
357
|
const handleSave = async () => {
|
|
357
358
|
setSaving(true)
|
|
358
|
-
//
|
|
359
|
-
|
|
360
|
-
//
|
|
359
|
+
// Feature flags and the other blocks share one preference row, so the two
|
|
360
|
+
// writes are sequenced: running them in parallel would make the second
|
|
361
|
+
// request merge onto settings read before the first one landed.
|
|
362
|
+
const featuresOk = await saveFeatures(features)
|
|
361
363
|
const [settingsOk, prefsOk] = await Promise.all([
|
|
362
364
|
saveSettingsToAPI(settings),
|
|
363
365
|
saveUserPrefs({ locale: settings.locale.language, signature }),
|
|
364
366
|
])
|
|
365
367
|
setSaving(false)
|
|
366
|
-
if (settingsOk && prefsOk) {
|
|
368
|
+
if (featuresOk && settingsOk && prefsOk) {
|
|
367
369
|
setSaved(true)
|
|
368
370
|
setTimeout(() => setSaved(false), 3000)
|
|
369
371
|
}
|
|
@@ -1,82 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* Ticketing feature flags — re-export of the shared implementation.
|
|
3
|
+
*
|
|
4
|
+
* See `utils/features.ts`. This module is kept so that
|
|
5
|
+
* `views/shared/index.ts` and existing view imports keep resolving.
|
|
5
6
|
*/
|
|
6
|
-
export
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
emailTracking: boolean
|
|
17
|
-
/** Canned responses: quick reply templates */
|
|
18
|
-
canned: boolean
|
|
19
|
-
/** Ticket merge: combine two tickets into one */
|
|
20
|
-
merge: boolean
|
|
21
|
-
/** Snooze: temporarily hide a ticket */
|
|
22
|
-
snooze: boolean
|
|
23
|
-
/** External messages: add messages received outside the system */
|
|
24
|
-
externalMessages: boolean
|
|
25
|
-
/** Client history: past tickets, projects, notes sidebar */
|
|
26
|
-
clientHistory: boolean
|
|
27
|
-
/** Activity log: audit trail of actions on the ticket */
|
|
28
|
-
activityLog: boolean
|
|
29
|
-
/** Split ticket: extract a message into a new ticket */
|
|
30
|
-
splitTicket: boolean
|
|
31
|
-
/** Scheduled replies: send a message at a future date */
|
|
32
|
-
scheduledReplies: boolean
|
|
33
|
-
/** Auto-close: automatically resolve inactive tickets */
|
|
34
|
-
autoClose: boolean
|
|
35
|
-
/** Auto-close delay in days (used by auto-close cron) */
|
|
36
|
-
autoCloseDays: number
|
|
37
|
-
/** Round-robin: distribute new tickets evenly among agents */
|
|
38
|
-
roundRobin: boolean
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/** Default features -- all enabled */
|
|
42
|
-
export const DEFAULT_FEATURES: TicketingFeatures = {
|
|
43
|
-
timeTracking: true,
|
|
44
|
-
ai: true,
|
|
45
|
-
satisfaction: true,
|
|
46
|
-
chat: true,
|
|
47
|
-
emailTracking: true,
|
|
48
|
-
canned: true,
|
|
49
|
-
merge: true,
|
|
50
|
-
snooze: true,
|
|
51
|
-
externalMessages: true,
|
|
52
|
-
clientHistory: true,
|
|
53
|
-
activityLog: true,
|
|
54
|
-
splitTicket: true,
|
|
55
|
-
scheduledReplies: true,
|
|
56
|
-
autoClose: true,
|
|
57
|
-
autoCloseDays: 7,
|
|
58
|
-
roundRobin: false,
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
const STORAGE_KEY = 'ticketing_features'
|
|
62
|
-
|
|
63
|
-
/** Read features from localStorage (falls back to defaults) */
|
|
64
|
-
export function getFeatures(): TicketingFeatures {
|
|
65
|
-
if (typeof window === 'undefined') return DEFAULT_FEATURES
|
|
66
|
-
try {
|
|
67
|
-
const stored = localStorage.getItem(STORAGE_KEY)
|
|
68
|
-
if (stored) {
|
|
69
|
-
const parsed = JSON.parse(stored)
|
|
70
|
-
return { ...DEFAULT_FEATURES, ...parsed }
|
|
71
|
-
}
|
|
72
|
-
} catch { /* ignore */ }
|
|
73
|
-
return DEFAULT_FEATURES
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/** Save features to localStorage */
|
|
77
|
-
export function saveFeatures(features: TicketingFeatures): void {
|
|
78
|
-
if (typeof window === 'undefined') return
|
|
79
|
-
try {
|
|
80
|
-
localStorage.setItem(STORAGE_KEY, JSON.stringify(features))
|
|
81
|
-
} catch { /* ignore */ }
|
|
82
|
-
}
|
|
7
|
+
export {
|
|
8
|
+
DEFAULT_TICKETING_FEATURES,
|
|
9
|
+
DEFAULT_TICKETING_FEATURES as DEFAULT_FEATURES,
|
|
10
|
+
FEATURES_CACHE_KEY,
|
|
11
|
+
fetchFeatures,
|
|
12
|
+
getFeatures,
|
|
13
|
+
normalizeFeatures,
|
|
14
|
+
saveFeatures,
|
|
15
|
+
} from '../../utils/features'
|
|
16
|
+
export type { TicketingFeatures } from '../../utils/features'
|