@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.
Files changed (159) hide show
  1. package/README.md +272 -35
  2. package/dist/components/RichTextEditor/index.js +1 -1
  3. package/dist/components/TicketConversation/RewriteDropdown.js +1 -1
  4. package/dist/components/TicketConversation/components/AISummaryPanel.js +3 -0
  5. package/dist/components/TicketConversation/components/ActionPanels.js +13 -8
  6. package/dist/components/TicketConversation/components/ClientHistory.js +1 -0
  7. package/dist/components/TicketConversation/components/CodeBlock.js +1 -0
  8. package/dist/components/TicketConversation/components/CodeBlockInserter.js +1 -1
  9. package/dist/components/TicketConversation/components/QuickActions.js +10 -8
  10. package/dist/components/TicketConversation/components/TicketHeader.js +2 -0
  11. package/dist/components/TicketConversation/components/TimeTrackingPanel.js +15 -9
  12. package/dist/components/TicketConversation/index.d.ts +10 -0
  13. package/dist/components/TicketConversation/index.js +25 -7
  14. package/dist/index.cjs +421 -73
  15. package/dist/index.d.cts +127 -38
  16. package/dist/index.d.ts +127 -38
  17. package/dist/index.js +415 -72
  18. package/dist/styles/BillingView.module.scss +11 -11
  19. package/dist/styles/ChatView.module.scss +7 -7
  20. package/dist/styles/CommandPalette.module.scss +1 -1
  21. package/dist/styles/CrmView.module.scss +9 -9
  22. package/dist/styles/EmailTracking.module.scss +6 -6
  23. package/dist/styles/ImportConversation.module.scss +5 -5
  24. package/dist/styles/Logs.module.scss +5 -5
  25. package/dist/styles/NewTicket.module.scss +2 -2
  26. package/dist/styles/PendingEmails.module.scss +13 -13
  27. package/dist/styles/SupportDashboard.module.scss +24 -8
  28. package/dist/styles/TicketDetail.module.scss +20 -20
  29. package/dist/styles/TicketInbox.module.scss +6 -6
  30. package/dist/styles/TicketingSettings.module.scss +10 -10
  31. package/dist/styles/TimeDashboard.module.scss +6 -6
  32. package/dist/styles/_tokens.scss +3 -1
  33. package/dist/utils/db.d.ts +34 -0
  34. package/dist/utils/readSettings.d.ts +90 -0
  35. package/dist/views/BillingView/client.js +15 -8
  36. package/dist/views/BillingView/index.js +4 -4
  37. package/dist/views/ChatView/client.js +2 -0
  38. package/dist/views/ChatView/index.js +4 -4
  39. package/dist/views/CrmView/client.js +2 -0
  40. package/dist/views/CrmView/index.js +4 -4
  41. package/dist/views/EmailTrackingView/client.js +23 -11
  42. package/dist/views/EmailTrackingView/index.js +4 -4
  43. package/dist/views/ImportConversationView/client.js +1 -0
  44. package/dist/views/ImportConversationView/index.js +4 -4
  45. package/dist/views/LogsView/client.js +1 -1
  46. package/dist/views/LogsView/index.js +4 -2
  47. package/dist/views/NewTicketView/client.js +20 -13
  48. package/dist/views/NewTicketView/index.js +4 -2
  49. package/dist/views/PendingEmailsView/client.js +9 -3
  50. package/dist/views/PendingEmailsView/index.js +4 -4
  51. package/dist/views/SupportDashboardView/client.js +17 -19
  52. package/dist/views/SupportDashboardView/index.js +4 -4
  53. package/dist/views/TicketDetailView/client.js +23 -11
  54. package/dist/views/TicketDetailView/index.js +4 -4
  55. package/dist/views/TicketInboxView/client.js +52 -26
  56. package/dist/views/TicketInboxView/index.js +4 -2
  57. package/dist/views/TicketingSettingsView/TicketingSettings.module.scss +7 -7
  58. package/dist/views/TicketingSettingsView/client.js +44 -26
  59. package/dist/views/TicketingSettingsView/index.js +4 -4
  60. package/dist/views/TimeDashboardView/client.js +10 -7
  61. package/dist/views/TimeDashboardView/index.js +4 -4
  62. package/dist/views/shared/ErrorBoundary.d.ts +36 -1
  63. package/dist/views/shared/ErrorBoundary.js +59 -28
  64. package/dist/views/shared/adminTokens.d.ts +15 -5
  65. package/dist/views/shared/adminTokens.js +23 -7
  66. package/dist/views/shared/icons.d.ts +49 -0
  67. package/dist/views/shared/icons.js +92 -0
  68. package/dist/views/shared/locales/en.json +15 -1
  69. package/dist/views/shared/locales/fr.json +15 -1
  70. package/dist/views/shared/viewAccess.d.ts +29 -0
  71. package/dist/views/shared/viewAccess.js +24 -0
  72. package/package.json +31 -21
  73. package/scripts/uninstall-data.mjs +178 -0
  74. package/scripts/uninstall.mjs +184 -0
  75. package/src/collections/Tickets.ts +7 -0
  76. package/src/collections/TimeEntries.ts +11 -3
  77. package/src/components/RichTextEditor/index.tsx +1 -1
  78. package/src/components/TicketConversation/RewriteDropdown.tsx +1 -1
  79. package/src/components/TicketConversation/components/AISummaryPanel.tsx +3 -0
  80. package/src/components/TicketConversation/components/ActionPanels.tsx +13 -8
  81. package/src/components/TicketConversation/components/ClientHistory.tsx +1 -0
  82. package/src/components/TicketConversation/components/CodeBlock.tsx +1 -0
  83. package/src/components/TicketConversation/components/CodeBlockInserter.tsx +1 -1
  84. package/src/components/TicketConversation/components/QuickActions.tsx +10 -8
  85. package/src/components/TicketConversation/components/TicketHeader.tsx +2 -0
  86. package/src/components/TicketConversation/components/TimeTrackingPanel.tsx +17 -9
  87. package/src/components/TicketConversation/index.tsx +41 -7
  88. package/src/endpoints/auth-2fa.ts +53 -8
  89. package/src/endpoints/capabilities.ts +2 -4
  90. package/src/endpoints/chatbot.ts +2 -2
  91. package/src/endpoints/delete-account.ts +191 -47
  92. package/src/endpoints/export-data.ts +104 -4
  93. package/src/endpoints/import-conversation.ts +2 -2
  94. package/src/endpoints/login.ts +13 -3
  95. package/src/endpoints/purge-logs.ts +6 -15
  96. package/src/endpoints/push.ts +14 -1
  97. package/src/endpoints/statuses.ts +17 -0
  98. package/src/index.ts +2 -0
  99. package/src/plugin.ts +51 -1
  100. package/src/portal/LiveChat.tsx +1 -1
  101. package/src/portal/auth/profile/page.tsx +3 -2
  102. package/src/portal/icons.tsx +91 -0
  103. package/src/portal/login/page.tsx +14 -4
  104. package/src/styles/BillingView.module.scss +11 -11
  105. package/src/styles/ChatView.module.scss +7 -7
  106. package/src/styles/CommandPalette.module.scss +1 -1
  107. package/src/styles/CrmView.module.scss +9 -9
  108. package/src/styles/EmailTracking.module.scss +6 -6
  109. package/src/styles/ImportConversation.module.scss +5 -5
  110. package/src/styles/Logs.module.scss +5 -5
  111. package/src/styles/NewTicket.module.scss +2 -2
  112. package/src/styles/PendingEmails.module.scss +13 -13
  113. package/src/styles/SupportDashboard.module.scss +24 -8
  114. package/src/styles/TicketDetail.module.scss +20 -20
  115. package/src/styles/TicketInbox.module.scss +6 -6
  116. package/src/styles/TicketingSettings.module.scss +10 -10
  117. package/src/styles/TimeDashboard.module.scss +6 -6
  118. package/src/styles/_tokens.scss +3 -1
  119. package/src/types.ts +20 -0
  120. package/src/utils/push.ts +22 -0
  121. package/src/utils/rateLimiter.ts +98 -0
  122. package/src/utils/retention.ts +111 -0
  123. package/src/utils/slugs.ts +15 -0
  124. package/src/utils/twoFactorChallenge.ts +6 -1
  125. package/src/utils/urlSafety.ts +36 -1
  126. package/src/views/BillingView/client.tsx +17 -8
  127. package/src/views/BillingView/index.tsx +4 -4
  128. package/src/views/ChatView/client.tsx +2 -0
  129. package/src/views/ChatView/index.tsx +4 -4
  130. package/src/views/CrmView/client.tsx +2 -0
  131. package/src/views/CrmView/index.tsx +4 -4
  132. package/src/views/EmailTrackingView/client.tsx +18 -7
  133. package/src/views/EmailTrackingView/index.tsx +4 -4
  134. package/src/views/ImportConversationView/client.tsx +1 -0
  135. package/src/views/ImportConversationView/index.tsx +4 -4
  136. package/src/views/LogsView/client.tsx +1 -1
  137. package/src/views/LogsView/index.tsx +4 -2
  138. package/src/views/NewTicketView/client.tsx +22 -13
  139. package/src/views/NewTicketView/index.tsx +4 -2
  140. package/src/views/PendingEmailsView/client.tsx +9 -3
  141. package/src/views/PendingEmailsView/index.tsx +4 -4
  142. package/src/views/SupportDashboardView/client.tsx +11 -5
  143. package/src/views/SupportDashboardView/index.tsx +4 -4
  144. package/src/views/TicketDetailView/client.tsx +25 -11
  145. package/src/views/TicketDetailView/index.tsx +4 -4
  146. package/src/views/TicketInboxView/client.tsx +47 -7
  147. package/src/views/TicketInboxView/index.tsx +4 -2
  148. package/src/views/TicketingSettingsView/TicketingSettings.module.scss +7 -7
  149. package/src/views/TicketingSettingsView/client.tsx +201 -144
  150. package/src/views/TicketingSettingsView/index.tsx +4 -4
  151. package/src/views/TimeDashboardView/client.tsx +10 -7
  152. package/src/views/TimeDashboardView/index.tsx +4 -4
  153. package/src/views/shared/ErrorBoundary.tsx +95 -31
  154. package/src/views/shared/adminTokens.ts +28 -11
  155. package/src/views/shared/icons.tsx +146 -0
  156. package/src/views/shared/locales/en.json +15 -1
  157. package/src/views/shared/locales/fr.json +15 -1
  158. package/src/views/shared/viewAccess.ts +73 -0
  159. package/src/types/lucide-react.d.ts +0 -42
@@ -0,0 +1,111 @@
1
+ import type { Payload, PayloadRequest } from 'payload'
2
+ import type { CollectionSlugs } from './slugs'
3
+ import { dbDelete } from './db'
4
+
5
+ /**
6
+ * How long the two journals are kept, in days.
7
+ *
8
+ * Defaults follow the CNIL guidance the README documents: six months would be
9
+ * the floor for connection logs, a year is the ceiling usually accepted, and
10
+ * these two values sit inside that range while staying long enough to
11
+ * investigate an intrusion. Set a value to 0 to disable the automatic purge of
12
+ * that journal — never to mean "delete everything", which is what `days=0`
13
+ * means on the manual endpoint.
14
+ */
15
+ export interface RetentionConfig {
16
+ /** Retention of `auth-logs` in days. `0` disables the scheduled purge. */
17
+ authLogsDays?: number
18
+ /** Retention of `email-logs` in days. `0` disables the scheduled purge. */
19
+ emailLogsDays?: number
20
+ /**
21
+ * Cron for the scheduled purge. Payload's cron takes an optional leading
22
+ * seconds field. Default: every day at 03:30.
23
+ */
24
+ cron?: string
25
+ /** Queue the scheduled job is pushed to. Default: `default`. */
26
+ queue?: string
27
+ }
28
+
29
+ export const DEFAULT_RETENTION = {
30
+ authLogsDays: 180,
31
+ emailLogsDays: 365,
32
+ cron: '0 30 3 * * *',
33
+ queue: 'default',
34
+ } as const
35
+
36
+ export const PURGE_LOGS_TASK_SLUG = 'support-purge-logs'
37
+
38
+ /** Journals the purge is allowed to touch, keyed by their public name. */
39
+ export function purgeableCollections(slugs: CollectionSlugs): Record<string, string> {
40
+ return {
41
+ 'email-logs': slugs.emailLogs,
42
+ 'auth-logs': slugs.authLogs,
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Delete rows of `slug` created more than `days` days ago.
48
+ *
49
+ * `days <= 0` means "everything" and is only reachable from the manual
50
+ * admin-only endpoint. The scheduled task refuses it: a misconfigured cron
51
+ * that wipes the whole security journal every night is a far worse outcome
52
+ * than a journal that grows.
53
+ */
54
+ export async function purgeOlderThan(
55
+ payload: Payload,
56
+ slug: string,
57
+ days: number,
58
+ req?: PayloadRequest,
59
+ ): Promise<number> {
60
+ const cutoff = days > 0 ? new Date(Date.now() - days * 86400000).toISOString() : null
61
+
62
+ const result = await dbDelete(payload, slug, {
63
+ where: cutoff
64
+ ? { createdAt: { less_than: cutoff } }
65
+ : { id: { exists: true } },
66
+ overrideAccess: true,
67
+ ...(req ? { req } : {}),
68
+ })
69
+
70
+ return Array.isArray(result?.docs) ? result.docs.length : 0
71
+ }
72
+
73
+ export interface ScheduledPurgeResult {
74
+ purged: Record<string, number>
75
+ skipped: string[]
76
+ }
77
+
78
+ /**
79
+ * The scheduled half of the purge. Never accepts `days <= 0`, and silently
80
+ * skips a journal whose collection is not registered (its feature flag is off).
81
+ */
82
+ export async function runScheduledPurge(
83
+ payload: Payload,
84
+ slugs: CollectionSlugs,
85
+ retention: RetentionConfig | false | undefined,
86
+ req?: PayloadRequest,
87
+ ): Promise<ScheduledPurgeResult> {
88
+ if (retention === false) return { purged: {}, skipped: ['auth-logs', 'email-logs'] }
89
+
90
+ const plan: Array<[string, string, number]> = [
91
+ ['auth-logs', slugs.authLogs, retention?.authLogsDays ?? DEFAULT_RETENTION.authLogsDays],
92
+ ['email-logs', slugs.emailLogs, retention?.emailLogsDays ?? DEFAULT_RETENTION.emailLogsDays],
93
+ ]
94
+
95
+ const purged: Record<string, number> = {}
96
+ const skipped: string[] = []
97
+
98
+ for (const [name, slug, days] of plan) {
99
+ if (!Number.isFinite(days) || days <= 0) {
100
+ skipped.push(name)
101
+ continue
102
+ }
103
+ if (!(payload.collections as Record<string, unknown> | undefined)?.[slug]) {
104
+ skipped.push(name)
105
+ continue
106
+ }
107
+ purged[name] = await purgeOlderThan(payload, slug, days, req)
108
+ }
109
+
110
+ return { purged, skipped }
111
+ }
@@ -58,6 +58,21 @@ export const DEFAULT_SLUGS: CollectionSlugs = {
58
58
  media: 'media',
59
59
  }
60
60
 
61
+ /**
62
+ * Collections the plugin registers under a FIXED slug.
63
+ *
64
+ * Unlike everything in `CollectionSlugs`, these three hardcode their `slug`
65
+ * (ClientSummaries.ts, TicketCollaborators.ts, TicketFeedback.ts), so any code
66
+ * that reads or deletes their rows must use these literals rather than a
67
+ * configurable slug — otherwise an app that overrides `collectionSlugs` would
68
+ * address a collection that does not exist.
69
+ */
70
+ export const FIXED_SLUGS = {
71
+ clientSummaries: 'client-summaries',
72
+ ticketCollaborators: 'ticket-collaborators',
73
+ ticketFeedback: 'ticket-feedback',
74
+ } as const
75
+
61
76
  /**
62
77
  * Resolve collection slugs merging user overrides with defaults.
63
78
  */
@@ -36,7 +36,12 @@ function challengeSecret(): string {
36
36
  return secret
37
37
  }
38
38
 
39
- function normalizeEmail(email: string): string {
39
+ /**
40
+ * The address the challenge is signed over. Also the rate-limit key of both 2FA
41
+ * branches: signing over the normalized form while keying the limiter on the raw
42
+ * one would give a single challenge one budget PER CASING VARIANT.
43
+ */
44
+ export function normalizeEmail(email: string): string {
40
45
  return String(email).trim().toLowerCase()
41
46
  }
42
47
 
@@ -2,7 +2,7 @@ import { lookup } from 'dns/promises'
2
2
 
3
3
  /**
4
4
  * SSRF guards for every URL the SERVER follows on behalf of a user-supplied
5
- * value (today: outbound webhook endpoints).
5
+ * value: outbound webhook endpoints, and Web Push subscription endpoints.
6
6
  *
7
7
  * `webhook-endpoints.url` is a plain `text` field with no validation, writable by
8
8
  * any member of the staff collection, and `_sendToEndpoint` used to `fetch()` it
@@ -144,6 +144,41 @@ export const WEBHOOK_URL_MESSAGES: Record<NonNullable<UrlValidationResult['reaso
144
144
  private_host: 'Les adresses privées, loopback et link-local sont interdites (SSRF).',
145
145
  }
146
146
 
147
+ /**
148
+ * A `push-subscriptions.endpoint` is the same shape of hole as a webhook URL,
149
+ * from the same actor: `requireAdmin` only checks that the caller belongs to the
150
+ * staff collection, the field is plain `text` with no `validate`, no collection
151
+ * hook rewrites it, and `web-push` then hands the hostname and PORT straight to
152
+ * `https.request` with no allowlist of its own. Pointing it at an internal
153
+ * service turned every client reply into an outbound request of the attacker's
154
+ * choosing, with a 1-bit oracle: a 404/410 deletes the row, and staff can read
155
+ * `push-subscriptions` back.
156
+ *
157
+ * Stricter than `validateWebhookUrl` on two points, both because `web-push`
158
+ * behaves differently from `fetch`:
159
+ * - https only, with NO `SUPPORT_ALLOW_INSECURE_WEBHOOKS` escape hatch —
160
+ * `web-push` calls `https.request` whatever the scheme says, so an `http://`
161
+ * endpoint is a broken subscription, not a dev convenience;
162
+ * - an explicit length cap, because unlike a webhook URL this value arrives
163
+ * from an HTTP body and is persisted verbatim.
164
+ */
165
+ const MAX_PUSH_ENDPOINT_LENGTH = 2048
166
+
167
+ export function validatePushEndpoint(raw: unknown): UrlValidationResult {
168
+ if (typeof raw !== 'string' || !raw.trim() || raw.length > MAX_PUSH_ENDPOINT_LENGTH) {
169
+ return { ok: false, reason: 'invalid_url' }
170
+ }
171
+ let url: URL
172
+ try {
173
+ url = new URL(raw.trim())
174
+ } catch {
175
+ return { ok: false, reason: 'invalid_url' }
176
+ }
177
+ if (url.protocol !== 'https:') return { ok: false, reason: 'scheme_not_allowed' }
178
+ if (isBlockedHost(url.hostname)) return { ok: false, reason: 'private_host' }
179
+ return { ok: true, url }
180
+ }
181
+
147
182
  /**
148
183
  * Resolve the name and reject unless EVERY resolved address is public.
149
184
  *
@@ -1,6 +1,6 @@
1
1
  'use client'
2
2
 
3
- import React, { useState, useCallback } from 'react'
3
+ import React, { useState, useCallback, useId } from 'react'
4
4
  import { useTranslation } from '../../components/TicketConversation/hooks/useTranslation'
5
5
  import { DATE_LOCALE } from '../shared/dateLocale'
6
6
  import styles from '../../styles/BillingView.module.scss'
@@ -86,6 +86,12 @@ function getQuarterRange(offset: number): { from: string; to: string } {
86
86
 
87
87
  export const BillingClient: React.FC = () => {
88
88
  const { t } = useTranslation()
89
+ // The `<label>`s in the filter row already carry the translated text; they
90
+ // just were not associated with their control.
91
+ const fromId = useId()
92
+ const toId = useId()
93
+ const projectFilterId = useId()
94
+ const rateId = useId()
89
95
  const [from, setFrom] = useState(() => getMonthRange(0).from)
90
96
  const [to, setTo] = useState(() => getMonthRange(0).to)
91
97
  const [projectId, setProjectId] = useState('')
@@ -287,16 +293,16 @@ export const BillingClient: React.FC = () => {
287
293
 
288
294
  <div className={styles.filterRow}>
289
295
  <div className={styles.fieldGroup}>
290
- <label className={styles.label}>{t('billing.filters.from')}</label>
291
- <input type="date" value={from} onChange={(e) => setFrom(e.target.value)} className={styles.input} />
296
+ <label className={styles.label} htmlFor={fromId}>{t('billing.filters.from')}</label>
297
+ <input id={fromId} type="date" value={from} onChange={(e) => setFrom(e.target.value)} className={styles.input} />
292
298
  </div>
293
299
  <div className={styles.fieldGroup}>
294
- <label className={styles.label}>{t('billing.filters.to')}</label>
295
- <input type="date" value={to} onChange={(e) => setTo(e.target.value)} className={styles.input} />
300
+ <label className={styles.label} htmlFor={toId}>{t('billing.filters.to')}</label>
301
+ <input id={toId} type="date" value={to} onChange={(e) => setTo(e.target.value)} className={styles.input} />
296
302
  </div>
297
303
  <div className={styles.fieldGroup}>
298
- <label className={styles.label}>{t('billing.filters.project')}</label>
299
- <select value={projectId} onChange={(e) => setProjectId(e.target.value)} className={styles.select}>
304
+ <label className={styles.label} htmlFor={projectFilterId}>{t('billing.filters.project')}</label>
305
+ <select id={projectFilterId} value={projectId} onChange={(e) => setProjectId(e.target.value)} className={styles.select}>
300
306
  <option value="">{t('ticket.allProjects')}</option>
301
307
  {projects.map((p) => (
302
308
  <option key={p.id} value={p.id}>{p.name}</option>
@@ -304,9 +310,10 @@ export const BillingClient: React.FC = () => {
304
310
  </select>
305
311
  </div>
306
312
  <div className={styles.fieldGroup}>
307
- <label className={styles.label}>{t('billing.filters.hourlyRate')}</label>
313
+ <label className={styles.label} htmlFor={rateId}>{t('billing.filters.hourlyRate')}</label>
308
314
  <div className={styles.rateRow}>
309
315
  <input
316
+ id={rateId}
310
317
  type="number"
311
318
  value={rate}
312
319
  onChange={(e) => setRate(Number(e.target.value))}
@@ -370,6 +377,7 @@ export const BillingClient: React.FC = () => {
370
377
  <th className={styles.thCheckbox}>
371
378
  <input
372
379
  type="checkbox"
380
+ aria-label={t('billing.tableCheckboxTitle')}
373
381
  checked={group.tickets.every((t) => billedTickets.has(t.id))}
374
382
  onChange={() => {
375
383
  const ids = group.tickets.map((t) => t.id)
@@ -405,6 +413,7 @@ export const BillingClient: React.FC = () => {
405
413
  <td className={styles.td} rowSpan={rowSpan} style={{ verticalAlign: 'middle' }}>
406
414
  <input
407
415
  type="checkbox"
416
+ aria-label={isBilled ? t('billing.markUnbilledTitle') : t('billing.markBilledTitle')}
408
417
  checked={isBilled}
409
418
  onChange={() => toggleBilled(ticket.id)}
410
419
  className={styles.checkbox}
@@ -1,6 +1,7 @@
1
1
  import type { AdminViewServerProps } from 'payload'
2
2
  import { DefaultTemplate } from '@payloadcms/next/templates'
3
3
  import { redirect } from 'next/navigation'
4
+ import { supportViewRedirectTarget } from '../shared/viewAccess.js'
4
5
  import React from 'react'
5
6
  import { AdminErrorBoundary } from '../shared/ErrorBoundary'
6
7
  import { BillingClient } from './client'
@@ -8,9 +9,8 @@ import { BillingClient } from './client'
8
9
  export const BillingView: React.FC<AdminViewServerProps> = ({ initPageResult }) => {
9
10
  const { req, visibleEntities } = initPageResult
10
11
 
11
- if (!req.user) {
12
- redirect('/admin/login')
13
- }
12
+ const redirectTo = supportViewRedirectTarget(initPageResult)
13
+ if (redirectTo) redirect(redirectTo)
14
14
 
15
15
  return (
16
16
  <DefaultTemplate
@@ -20,7 +20,7 @@ export const BillingView: React.FC<AdminViewServerProps> = ({ initPageResult })
20
20
  payload={req.payload}
21
21
  permissions={initPageResult.permissions}
22
22
  searchParams={{}}
23
- user={req.user}
23
+ user={req.user ?? undefined}
24
24
  visibleEntities={visibleEntities}
25
25
  >
26
26
  <AdminErrorBoundary viewName="BillingView">
@@ -343,6 +343,7 @@ export const ChatViewClient: React.FC = () => {
343
343
  <form onSubmit={sendMessage} className={styles.composer}>
344
344
  {cannedResponses.length > 0 && (
345
345
  <select
346
+ aria-label={t('chat.quickReply')}
346
347
  onChange={(e) => {
347
348
  const cr = cannedResponses.find((c) => String(c.id) === e.target.value)
348
349
  if (cr) setInput(cr.body)
@@ -359,6 +360,7 @@ export const ChatViewClient: React.FC = () => {
359
360
  <div className={styles.composerRow}>
360
361
  <input
361
362
  type="text"
363
+ aria-label={t('chat.inputPlaceholder')}
362
364
  value={input}
363
365
  onChange={(e) => setInput(e.target.value)}
364
366
  placeholder={t('chat.inputPlaceholder')}
@@ -1,6 +1,7 @@
1
1
  import type { AdminViewServerProps } from 'payload'
2
2
  import { DefaultTemplate } from '@payloadcms/next/templates'
3
3
  import { redirect } from 'next/navigation'
4
+ import { supportViewRedirectTarget } from '../shared/viewAccess.js'
4
5
  import React from 'react'
5
6
  import { AdminErrorBoundary } from '../shared/ErrorBoundary'
6
7
  import { ChatViewClient } from './client'
@@ -8,9 +9,8 @@ import { ChatViewClient } from './client'
8
9
  export const ChatView: React.FC<AdminViewServerProps> = ({ initPageResult }) => {
9
10
  const { req, visibleEntities } = initPageResult
10
11
 
11
- if (!req.user) {
12
- redirect('/admin/login')
13
- }
12
+ const redirectTo = supportViewRedirectTarget(initPageResult)
13
+ if (redirectTo) redirect(redirectTo)
14
14
 
15
15
  return (
16
16
  <DefaultTemplate
@@ -20,7 +20,7 @@ export const ChatView: React.FC<AdminViewServerProps> = ({ initPageResult }) =>
20
20
  payload={req.payload}
21
21
  permissions={initPageResult.permissions}
22
22
  searchParams={{}}
23
- user={req.user}
23
+ user={req.user ?? undefined}
24
24
  visibleEntities={visibleEntities}
25
25
  >
26
26
  <AdminErrorBoundary viewName="ChatView">
@@ -275,6 +275,7 @@ export const CrmClient: React.FC = () => {
275
275
  <div className={styles.sidebarSearch}>
276
276
  <input
277
277
  type="text"
278
+ aria-label={t('crm.searchLabel')}
278
279
  placeholder={t('crm.searchPlaceholder')}
279
280
  value={search}
280
281
  onChange={(e) => setSearch(e.target.value)}
@@ -379,6 +380,7 @@ export const CrmClient: React.FC = () => {
379
380
  </p>
380
381
  <input
381
382
  type="text"
383
+ aria-label={t('crm.mergeTitle')}
382
384
  value={mergeSearch}
383
385
  onChange={(e) => setMergeSearch(e.target.value)}
384
386
  placeholder={t('crm.mergeSearchPlaceholder')}
@@ -1,6 +1,7 @@
1
1
  import type { AdminViewServerProps } from 'payload'
2
2
  import { DefaultTemplate } from '@payloadcms/next/templates'
3
3
  import { redirect } from 'next/navigation'
4
+ import { supportViewRedirectTarget } from '../shared/viewAccess.js'
4
5
  import React from 'react'
5
6
  import { AdminErrorBoundary } from '../shared/ErrorBoundary'
6
7
  import { CrmClient } from './client'
@@ -8,9 +9,8 @@ import { CrmClient } from './client'
8
9
  export const CrmView: React.FC<AdminViewServerProps> = ({ initPageResult }) => {
9
10
  const { req, visibleEntities } = initPageResult
10
11
 
11
- if (!req.user) {
12
- redirect('/admin/login')
13
- }
12
+ const redirectTo = supportViewRedirectTarget(initPageResult)
13
+ if (redirectTo) redirect(redirectTo)
14
14
 
15
15
  return (
16
16
  <DefaultTemplate
@@ -20,7 +20,7 @@ export const CrmView: React.FC<AdminViewServerProps> = ({ initPageResult }) => {
20
20
  payload={req.payload}
21
21
  permissions={initPageResult.permissions}
22
22
  searchParams={{}}
23
- user={req.user}
23
+ user={req.user ?? undefined}
24
24
  visibleEntities={visibleEntities}
25
25
  >
26
26
  <AdminErrorBoundary viewName="CrmView">
@@ -2,6 +2,7 @@
2
2
 
3
3
  import React, { useState, useEffect, useCallback } from 'react'
4
4
  import { useTranslation } from '../../components/TicketConversation/hooks/useTranslation'
5
+ import { V } from '../shared/adminTokens'
5
6
  import { DATE_LOCALE } from '../shared/dateLocale'
6
7
  import s from '../../styles/EmailTracking.module.scss'
7
8
 
@@ -111,7 +112,7 @@ export const EmailTrackingClient: React.FC = () => {
111
112
  <button key={v} className={`${s.dateRangeBtn} ${dateRange === v ? s.dateRangeActive : ''}`} onClick={() => setDateRange(v)}>{v}j</button>
112
113
  ))}
113
114
  </div>
114
- <input type="text" className={s.searchInput} placeholder={t('emailTracking.searchPlaceholder')} value={search} onChange={(e) => setSearch(e.target.value)} />
115
+ <input type="text" className={s.searchInput} aria-label={t('emailTracking.searchLabel')} placeholder={t('emailTracking.searchPlaceholder')} value={search} onChange={(e) => setSearch(e.target.value)} />
115
116
  </div>
116
117
 
117
118
  {/* Table */}
@@ -125,7 +126,7 @@ export const EmailTrackingClient: React.FC = () => {
125
126
  <tbody>
126
127
  {logs.map((log) => (
127
128
  <React.Fragment key={log.id}>
128
- <tr onClick={() => log.status === 'error' && log.errorMessage ? setExpandedRow(expandedRow === log.id ? null : log.id) : null} style={{ cursor: log.status === 'error' && log.errorMessage ? 'pointer' : 'default' }}>
129
+ <tr>
129
130
  <td style={{ fontSize: 12, whiteSpace: 'nowrap' }}>{fmtDate(log.createdAt)}</td>
130
131
  <td style={{ maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={log.recipientEmail}>{log.recipientEmail || '—'}</td>
131
132
  <td style={{ maxWidth: 250, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={log.subject}>{log.subject || '—'}</td>
@@ -137,14 +138,24 @@ export const EmailTrackingClient: React.FC = () => {
137
138
  <span className={`${s.processingTime} ${log.processingTimeMs > 2000 ? s.timeSlow : log.processingTimeMs > 500 ? s.timeMedium : s.timeFast}`}>{log.processingTimeMs}ms</span>
138
139
  ) : '—'}
139
140
  {log.status === 'error' && log.errorMessage && (
140
- <button className={s.expandBtn} onClick={(e) => { e.stopPropagation(); setExpandedRow(expandedRow === log.id ? null : log.id) }}>
141
+ /* The row itself carries no handler: a `<tr onClick>` is
142
+ * unreachable by keyboard and role="button" on a row would
143
+ * destroy the table semantics. This button is the control. */
144
+ <button
145
+ type="button"
146
+ className={s.expandBtn}
147
+ aria-expanded={expandedRow === log.id}
148
+ aria-controls={`email-error-${log.id}`}
149
+ aria-label={t('emailTracking.toggleError')}
150
+ onClick={() => setExpandedRow(expandedRow === log.id ? null : log.id)}
151
+ >
141
152
  {expandedRow === log.id ? '▴' : '▾'}
142
153
  </button>
143
154
  )}
144
155
  </td>
145
156
  </tr>
146
157
  {expandedRow === log.id && log.errorMessage && (
147
- <tr><td colSpan={7}><div className={s.errorDetail}>Erreur{log.httpStatus ? ` (HTTP ${log.httpStatus})` : ''}: {log.errorMessage}</div></td></tr>
158
+ <tr><td colSpan={7}><div className={s.errorDetail} id={`email-error-${log.id}`}>{t('emailTracking.errorLabel')}{log.httpStatus ? ` (HTTP ${log.httpStatus})` : ''}: {log.errorMessage}</div></td></tr>
148
159
  )}
149
160
  </React.Fragment>
150
161
  ))}
@@ -155,9 +166,9 @@ export const EmailTrackingClient: React.FC = () => {
155
166
  {/* Pagination */}
156
167
  {totalDocs > 0 && (
157
168
  <div className={s.pagination}>
158
- <button className={s.pageBtn} onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={page <= 1}>{t('common.previous')}</button>
159
- <span style={{ fontSize: 12, color: 'var(--theme-elevation-500)', alignSelf: 'center' }}>{t('common.page')} {page} — {totalDocs} {t('common.results')}</span>
160
- <button className={s.pageBtn} onClick={() => setPage((p) => p + 1)} disabled={!hasMore}>{t('common.next')}</button>
169
+ <button type="button" className={s.pageBtn} onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={page <= 1}>{t('common.previous')}</button>
170
+ <span style={{ fontSize: 12, color: V.textSecondary, alignSelf: 'center' }}>{t('common.page')} {page} — {totalDocs} {t('common.results')}</span>
171
+ <button type="button" className={s.pageBtn} onClick={() => setPage((p) => p + 1)} disabled={!hasMore}>{t('common.next')}</button>
161
172
  </div>
162
173
  )}
163
174
  </div>
@@ -1,6 +1,7 @@
1
1
  import type { AdminViewServerProps } from 'payload'
2
2
  import { DefaultTemplate } from '@payloadcms/next/templates'
3
3
  import { redirect } from 'next/navigation'
4
+ import { supportViewRedirectTarget } from '../shared/viewAccess.js'
4
5
  import React from 'react'
5
6
  import { AdminErrorBoundary } from '../shared/ErrorBoundary'
6
7
  import { EmailTrackingClient } from './client'
@@ -8,9 +9,8 @@ import { EmailTrackingClient } from './client'
8
9
  export const EmailTrackingView: React.FC<AdminViewServerProps> = ({ initPageResult }) => {
9
10
  const { req, visibleEntities } = initPageResult
10
11
 
11
- if (!req.user) {
12
- redirect('/admin/login')
13
- }
12
+ const redirectTo = supportViewRedirectTarget(initPageResult)
13
+ if (redirectTo) redirect(redirectTo)
14
14
 
15
15
  return (
16
16
  <DefaultTemplate
@@ -20,7 +20,7 @@ export const EmailTrackingView: React.FC<AdminViewServerProps> = ({ initPageResu
20
20
  payload={req.payload}
21
21
  permissions={initPageResult.permissions}
22
22
  searchParams={{}}
23
- user={req.user}
23
+ user={req.user ?? undefined}
24
24
  visibleEntities={visibleEntities}
25
25
  >
26
26
  <AdminErrorBoundary viewName="EmailTrackingView">
@@ -192,6 +192,7 @@ export function ImportConversationClient() {
192
192
  ref={fileRef}
193
193
  type="file"
194
194
  accept=".md,.txt"
195
+ aria-label={t('import.acceptedFormats')}
195
196
  onChange={onFileChange}
196
197
  style={{ display: 'none' }}
197
198
  />
@@ -1,6 +1,7 @@
1
1
  import type { AdminViewServerProps } from 'payload'
2
2
  import { DefaultTemplate } from '@payloadcms/next/templates'
3
3
  import { redirect } from 'next/navigation'
4
+ import { supportViewRedirectTarget } from '../shared/viewAccess.js'
4
5
  import React from 'react'
5
6
  import { AdminErrorBoundary } from '../shared/ErrorBoundary'
6
7
  import { ImportConversationClient } from './client'
@@ -8,9 +9,8 @@ import { ImportConversationClient } from './client'
8
9
  export const ImportConversationView: React.FC<AdminViewServerProps> = ({ initPageResult }) => {
9
10
  const { req, visibleEntities } = initPageResult
10
11
 
11
- if (!req.user) {
12
- redirect('/admin/login')
13
- }
12
+ const redirectTo = supportViewRedirectTarget(initPageResult)
13
+ if (redirectTo) redirect(redirectTo)
14
14
 
15
15
  return (
16
16
  <DefaultTemplate
@@ -20,7 +20,7 @@ export const ImportConversationView: React.FC<AdminViewServerProps> = ({ initPag
20
20
  payload={req.payload}
21
21
  permissions={initPageResult.permissions}
22
22
  searchParams={{}}
23
- user={req.user}
23
+ user={req.user ?? undefined}
24
24
  visibleEntities={visibleEntities}
25
25
  >
26
26
  <AdminErrorBoundary viewName="ImportConversationView">
@@ -131,7 +131,7 @@ export const LogsClient: React.FC = () => {
131
131
  </td>
132
132
  <td className={s.truncate} title={log.recipientEmail}>{log.recipientEmail || '—'}</td>
133
133
  <td className={s.truncate} title={log.subject}>{log.subject || '—'}</td>
134
- <td style={{ fontSize: 12, color: 'var(--theme-elevation-500)' }}>{log.action || '—'}</td>
134
+ <td style={{ fontSize: 12, color: 'var(--theme-elevation-650)' }}>{log.action || '—'}</td>
135
135
  <td style={{ textAlign: 'right' }}>
136
136
  {log.processingTimeMs != null ? (
137
137
  <span className={s.mono} style={{ color: log.processingTimeMs > 2000 ? '#dc2626' : log.processingTimeMs > 500 ? '#d97706' : '#16a34a' }}>
@@ -1,13 +1,15 @@
1
1
  import type { AdminViewServerProps } from 'payload'
2
2
  import { DefaultTemplate } from '@payloadcms/next/templates'
3
3
  import { redirect } from 'next/navigation'
4
+ import { supportViewRedirectTarget } from '../shared/viewAccess.js'
4
5
  import React from 'react'
5
6
  import { AdminErrorBoundary } from '../shared/ErrorBoundary'
6
7
  import { LogsClient } from './client'
7
8
 
8
9
  export const LogsView: React.FC<AdminViewServerProps> = ({ initPageResult }) => {
9
10
  const { req, visibleEntities } = initPageResult
10
- if (!req.user) redirect('/admin/login')
11
+ const redirectTo = supportViewRedirectTarget(initPageResult)
12
+ if (redirectTo) redirect(redirectTo)
11
13
 
12
14
  return (
13
15
  <DefaultTemplate
@@ -17,7 +19,7 @@ export const LogsView: React.FC<AdminViewServerProps> = ({ initPageResult }) =>
17
19
  payload={req.payload}
18
20
  permissions={initPageResult.permissions}
19
21
  searchParams={{}}
20
- user={req.user}
22
+ user={req.user ?? undefined}
21
23
  visibleEntities={visibleEntities}
22
24
  >
23
25
  <AdminErrorBoundary viewName="LogsView">