@consilioweb/payload-support 2.0.0 → 2.0.1

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 CHANGED
@@ -9,7 +9,7 @@
9
9
  [![MIT License](https://img.shields.io/badge/license-MIT-1f8a5b)](LICENSE)
10
10
  [![Node.js](https://img.shields.io/badge/node-18+-1f8a5b)](https://nodejs.org)
11
11
  [![Payload](https://img.shields.io/badge/payload-3.x-1f8a5b)](https://payloadcms.com)
12
- [![Tests](https://img.shields.io/badge/tests-102%20passing-1f8a5b)](src/__tests__)
12
+ [![Tests](https://img.shields.io/badge/tests-132%20passing-1f8a5b)](src/__tests__)
13
13
  [![TypeScript](https://img.shields.io/badge/typescript-strict-1f8a5b)](https://www.typescriptlang.org)
14
14
 
15
15
  </div>
@@ -154,6 +154,8 @@ supportPlugin({ features: { chat: false, pendingEmails: false }, locale: 'en' })
154
154
  ```ts
155
155
  supportPlugin({
156
156
  features: { ai: true, sla: true, roundRobin: true, webhooks: true, snooze: true },
157
+ rateLimitStore: 'payload', // shared, persistent limits for multi-instance deployments
158
+ ticketNumber: { prefix: 'TK-', padding: 6 },
157
159
  ai: { provider: 'ollama', model: 'qwen2.5', baseUrl: process.env.OLLAMA_API_URL },
158
160
  email: { fromName: 'Support ACME', fromAddress: 'support@acme.com', replyTo: 'support@acme.com' },
159
161
  allowedEmailDomains: ['acme.com'], // restrict OAuth auto-registration
@@ -163,6 +165,50 @@ supportPlugin({
163
165
  })
164
166
  ```
165
167
 
168
+ ### Deployment adapters
169
+
170
+ Version 2 keeps provider-specific code in the host application while the plugin owns the
171
+ generic support workflow. Optional capabilities include SMS, inbound email, digests, AI
172
+ titles and summaries, detailed billing, volunteering, thread cleanup and project suggestions.
173
+
174
+ ```ts
175
+ supportPlugin({
176
+ rateLimitStore: 'payload',
177
+ capabilities: {
178
+ sms: {
179
+ adapter: {
180
+ isConfigured: () => Boolean(process.env.SMS_PROVIDER_ACCOUNT),
181
+ send: async ({ message, to }) => mySmsProvider.send({ message, to }),
182
+ },
183
+ },
184
+ inboundEmail: {
185
+ secret: process.env.SUPPORT_WEBHOOK_SECRET,
186
+ secretHeader: 'x-webhook-secret',
187
+ handle: handleInboundSupportEmail,
188
+ },
189
+ detailedBilling: true,
190
+ volunteering: true,
191
+ },
192
+ })
193
+ ```
194
+
195
+ ### Upgrading from 1.x
196
+
197
+ Version 2.0 is a security and ownership release with intentional breaking changes:
198
+
199
+ 1. Generate an additive Payload migration for the new `support-counters` collection and,
200
+ when `rateLimitStore: 'payload'` is enabled, `support-rate-limits`. Run it before restart.
201
+ 2. Regenerate Payload types and the admin import map.
202
+ 3. Remove duplicated host routes and enable the plugin endpoints (`skipEndpoints: false`).
203
+ 4. Read portal authentication exclusively from the `HttpOnly` cookie. Login, OAuth and 2FA
204
+ responses no longer expose the JWT in JSON.
205
+ 5. Send cron and webhook secrets only through their configured headers. Query-string secrets
206
+ are rejected.
207
+ 6. Custom rate-limit stores must implement the asynchronous `RateLimitStore` interface.
208
+
209
+ The process-local memory store remains the default for development and single-instance use.
210
+ Use `rateLimitStore: 'payload'` or a shared custom store in production with multiple instances.
211
+
166
212
  ### Run the AI agent on a ticket
167
213
 
168
214
  ```ts
@@ -212,6 +258,9 @@ import type { SupportPluginConfig, SupportFeatures } from '@consilioweb/payload-
212
258
  | `features` | `SupportFeatures` | all `true` | Toggle each feature on/off. |
213
259
  | `ai` | `AIProviderConfig` | `anthropic` | AI provider: `anthropic` \| `openai` \| `ollama` \| `custom`. |
214
260
  | `email` | `EmailConfig` | — | `fromName`, `fromAddress`, `replyTo`. |
261
+ | `rateLimitStore` | `RateLimitStore \| 'payload'` | memory | Persistent/shared storage for endpoint limits. |
262
+ | `ticketNumber` | `{ prefix?, padding? }` | `TK-`, no padding | Atomic sequential ticket-number formatting. |
263
+ | `capabilities` | `SupportCapabilities` | — | Optional host adapters for SMS, inbound email, AI and deployment-specific workflows. |
215
264
  | `locale` | `'fr' \| 'en'` | `'fr'` | Admin/portal language. |
216
265
  | `basePath` | `string` | `'/support'` | Admin views prefix. |
217
266
  | `userCollectionSlug` | `string` | `'users'` | Agents collection. |
@@ -333,7 +382,10 @@ Security is a first-class concern — several guardrails are validated by integr
333
382
  - **Cross-client isolation** — a client can never read another's tickets/messages (filtered by owned tickets).
334
383
  - **2FA** enforced server-side (`beforeLogin`); **OAuth** verifies the Google email.
335
384
  - **Sanitization** of message HTML server-side (stored-XSS protection).
336
- - **HMAC-signed** webhooks, signed tracking pixel, fail-closed secrets.
385
+ - **HMAC-signed** webhooks and tracking pixels with constant-time verification and idempotent writes.
386
+ - **Cookie-only JWTs** for portal authentication (`HttpOnly`, `Secure`, `SameSite=Lax`).
387
+ - **Persistent rate limiting** for authentication, 2FA, chats, invitations, transfers and AI endpoints.
388
+ - **Bounded inbound email** payloads and attachments; cron/webhook secrets are accepted in headers only.
337
389
 
338
390
  ### Reporting Security Issues
339
391
 
@@ -362,7 +414,7 @@ Contributions are very welcome!
362
414
  Run the checks before submitting:
363
415
 
364
416
  ```bash
365
- npm run typecheck && npm test && npm run build
417
+ pnpm typecheck && pnpm test && pnpm build
366
418
  ```
367
419
 
368
420
  <img src="https://raw.githubusercontent.com/andreasbm/readme/master/assets/lines/rainbow.png" width="100%" alt="" />
@@ -371,6 +423,21 @@ npm run typecheck && npm test && npm run build
371
423
 
372
424
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
373
425
 
426
+ ### [2.0.1] — 2026-07-16
427
+
428
+ - 🌍 Loads the complete shared FR/EN catalogs in every support admin view, fixing raw keys such as ticket statuses, relative dates, time tracking, tags and billing labels.
429
+ - 🧭 Removes translation-key collisions in the support dashboard and settings views.
430
+ - ✅ Adds regression coverage for all 518 literal translation keys used by the support UI and keeps both locales aligned (132 tests passing).
431
+
432
+ ### [2.0.0] — 2026-07-16
433
+
434
+ - 🔐 Security hardening for authentication, tracking pixels, webhook/cron secrets, HTML/AI output and inbound-email limits.
435
+ - 🚦 Asynchronous persistent rate limiting with a Payload-backed store and memory fallback.
436
+ - 🔢 Atomic persistent ticket counters with configurable prefix and padding.
437
+ - 🧩 Typed deployment adapters for SMS, inbound email, AI workflows, billing, volunteering, cleanup and project suggestions.
438
+ - 🏗️ The plugin is now the single owner of generic support collections, hooks, views and endpoints.
439
+ - ✅ 124 tests covering the security regressions and new capabilities.
440
+
374
441
  ### [1.1.1] — 2026-06-26
375
442
 
376
443
  - 🐛 **Fix client ticket reopen**: allow clients to set `waiting_support` (the status the portal's "Reopen" button sends). It was silently rejected, leaving the ticket `resolved`.
@@ -3,17 +3,38 @@
3
3
  var react = require('react');
4
4
  var frTranslations = require('../locales/fr.json');
5
5
  var enTranslations = require('../locales/en.json');
6
+ var frViewTranslations = require('../../../views/shared/locales/fr.json');
7
+ var enViewTranslations = require('../../../views/shared/locales/en.json');
6
8
 
7
9
  function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
8
10
 
9
11
  var frTranslations__default = /*#__PURE__*/_interopDefault(frTranslations);
10
12
  var enTranslations__default = /*#__PURE__*/_interopDefault(enTranslations);
13
+ var frViewTranslations__default = /*#__PURE__*/_interopDefault(frViewTranslations);
14
+ var enViewTranslations__default = /*#__PURE__*/_interopDefault(enViewTranslations);
11
15
 
12
16
  const LOCALE_STORAGE_KEY = "support_locale";
13
17
  const DEFAULT_LOCALE = "fr";
18
+ function mergeTranslations(base, supplement) {
19
+ const merged = { ...base };
20
+ for (const [key, value] of Object.entries(supplement)) {
21
+ const current = merged[key];
22
+ if (current != null && value != null && typeof current === "object" && typeof value === "object" && !Array.isArray(current) && !Array.isArray(value)) {
23
+ merged[key] = mergeTranslations(
24
+ current,
25
+ value
26
+ );
27
+ continue;
28
+ }
29
+ if (current === void 0 || typeof current !== "object" && typeof value === "object") {
30
+ merged[key] = value;
31
+ }
32
+ }
33
+ return merged;
34
+ }
14
35
  const translations = {
15
- fr: frTranslations__default.default,
16
- en: enTranslations__default.default
36
+ fr: mergeTranslations(frTranslations__default.default, frViewTranslations__default.default),
37
+ en: mergeTranslations(enTranslations__default.default, enViewTranslations__default.default)
17
38
  };
18
39
  function getNestedValue(obj, path) {
19
40
  const keys = path.split(".");
@@ -75,4 +96,6 @@ function useTranslation() {
75
96
 
76
97
  exports.DEFAULT_LOCALE = DEFAULT_LOCALE;
77
98
  exports.LOCALE_STORAGE_KEY = LOCALE_STORAGE_KEY;
99
+ exports.getNestedValue = getNestedValue;
100
+ exports.translations = translations;
78
101
  exports.useTranslation = useTranslation;
@@ -2,12 +2,31 @@
2
2
  import { useState, useEffect, useCallback } from 'react';
3
3
  import frTranslations from '../locales/fr.json';
4
4
  import enTranslations from '../locales/en.json';
5
+ import frViewTranslations from '../../../views/shared/locales/fr.json';
6
+ import enViewTranslations from '../../../views/shared/locales/en.json';
5
7
 
6
8
  const LOCALE_STORAGE_KEY = "support_locale";
7
9
  const DEFAULT_LOCALE = "fr";
10
+ function mergeTranslations(base, supplement) {
11
+ const merged = { ...base };
12
+ for (const [key, value] of Object.entries(supplement)) {
13
+ const current = merged[key];
14
+ if (current != null && value != null && typeof current === "object" && typeof value === "object" && !Array.isArray(current) && !Array.isArray(value)) {
15
+ merged[key] = mergeTranslations(
16
+ current,
17
+ value
18
+ );
19
+ continue;
20
+ }
21
+ if (current === void 0 || typeof current !== "object" && typeof value === "object") {
22
+ merged[key] = value;
23
+ }
24
+ }
25
+ return merged;
26
+ }
8
27
  const translations = {
9
- fr: frTranslations,
10
- en: enTranslations
28
+ fr: mergeTranslations(frTranslations, frViewTranslations),
29
+ en: mergeTranslations(enTranslations, enViewTranslations)
11
30
  };
12
31
  function getNestedValue(obj, path) {
13
32
  const keys = path.split(".");
@@ -67,4 +86,4 @@ function useTranslation() {
67
86
  return { t, locale, setLocale };
68
87
  }
69
88
 
70
- export { DEFAULT_LOCALE, LOCALE_STORAGE_KEY, useTranslation };
89
+ export { DEFAULT_LOCALE, LOCALE_STORAGE_KEY, getNestedValue, translations, useTranslation };
@@ -406,7 +406,7 @@ const SupportDashboardClient = () => {
406
406
  /* @__PURE__ */ jsxRuntime.jsx(VolumeChart, { data: volumeData })
407
407
  ] }),
408
408
  /* @__PURE__ */ jsxRuntime.jsxs("div", { className: styles__default.default.panel, children: [
409
- /* @__PURE__ */ jsxRuntime.jsx("h2", { className: styles__default.default.panelTitle, children: t("dashboard.csat") }),
409
+ /* @__PURE__ */ jsxRuntime.jsx("h2", { className: styles__default.default.panelTitle, children: t("dashboard.csatTitle") }),
410
410
  /* @__PURE__ */ jsxRuntime.jsx(CSATRing, { score: stats.satisfactionAvg, count: stats.satisfactionCount })
411
411
  ] })
412
412
  ] })
@@ -400,7 +400,7 @@ const SupportDashboardClient = () => {
400
400
  /* @__PURE__ */ jsx(VolumeChart, { data: volumeData })
401
401
  ] }),
402
402
  /* @__PURE__ */ jsxs("div", { className: styles.panel, children: [
403
- /* @__PURE__ */ jsx("h2", { className: styles.panelTitle, children: t("dashboard.csat") }),
403
+ /* @__PURE__ */ jsx("h2", { className: styles.panelTitle, children: t("dashboard.csatTitle") }),
404
404
  /* @__PURE__ */ jsx(CSATRing, { score: stats.satisfactionAvg, count: stats.satisfactionCount })
405
405
  ] })
406
406
  ] })
@@ -305,7 +305,7 @@ const TicketingSettingsClient = () => {
305
305
  /* @__PURE__ */ jsxRuntime.jsxs(
306
306
  CollapsibleSection,
307
307
  {
308
- title: t("settingsView.features"),
308
+ title: t("settingsView.featuresTitle"),
309
309
  icon: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Settings, { size: 16 }),
310
310
  color: adminTokens.V.blue,
311
311
  badge: /* @__PURE__ */ jsxRuntime.jsxs("span", { className: ts__default.default.badge, style: { backgroundColor: "#dbeafe", color: "#1e40af" }, children: [
@@ -299,7 +299,7 @@ const TicketingSettingsClient = () => {
299
299
  /* @__PURE__ */ jsxs(
300
300
  CollapsibleSection,
301
301
  {
302
- title: t("settingsView.features"),
302
+ title: t("settingsView.featuresTitle"),
303
303
  icon: /* @__PURE__ */ jsx(Settings, { size: 16 }),
304
304
  color: V.blue,
305
305
  badge: /* @__PURE__ */ jsxs("span", { className: ts.badge, style: { backgroundColor: "#dbeafe", color: "#1e40af" }, children: [