@consilioweb/payload-support 4.0.0 → 6.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +272 -35
- package/dist/components/RichTextEditor/index.js +1 -1
- package/dist/components/TicketConversation/RewriteDropdown.js +1 -1
- package/dist/components/TicketConversation/components/AISummaryPanel.js +3 -0
- package/dist/components/TicketConversation/components/ActionPanels.js +13 -8
- package/dist/components/TicketConversation/components/ClientHistory.js +1 -0
- package/dist/components/TicketConversation/components/CodeBlock.js +1 -0
- package/dist/components/TicketConversation/components/CodeBlockInserter.js +1 -1
- package/dist/components/TicketConversation/components/QuickActions.js +10 -8
- package/dist/components/TicketConversation/components/TicketHeader.js +2 -0
- package/dist/components/TicketConversation/components/TimeTrackingPanel.js +15 -9
- package/dist/components/TicketConversation/index.d.ts +10 -0
- package/dist/components/TicketConversation/index.js +25 -7
- package/dist/index.cjs +421 -73
- package/dist/index.d.cts +127 -38
- package/dist/index.d.ts +127 -38
- package/dist/index.js +415 -72
- package/dist/styles/BillingView.module.scss +11 -11
- package/dist/styles/ChatView.module.scss +7 -7
- package/dist/styles/CommandPalette.module.scss +1 -1
- package/dist/styles/CrmView.module.scss +9 -9
- package/dist/styles/EmailTracking.module.scss +6 -6
- package/dist/styles/ImportConversation.module.scss +5 -5
- package/dist/styles/Logs.module.scss +5 -5
- package/dist/styles/NewTicket.module.scss +2 -2
- package/dist/styles/PendingEmails.module.scss +13 -13
- package/dist/styles/SupportDashboard.module.scss +24 -8
- package/dist/styles/TicketDetail.module.scss +20 -20
- package/dist/styles/TicketInbox.module.scss +6 -6
- package/dist/styles/TicketingSettings.module.scss +10 -10
- package/dist/styles/TimeDashboard.module.scss +6 -6
- package/dist/styles/_tokens.scss +3 -1
- package/dist/utils/db.d.ts +34 -0
- package/dist/utils/readSettings.d.ts +90 -0
- package/dist/views/BillingView/client.js +15 -8
- package/dist/views/BillingView/index.js +4 -4
- package/dist/views/ChatView/client.js +2 -0
- package/dist/views/ChatView/index.js +4 -4
- package/dist/views/CrmView/client.js +2 -0
- package/dist/views/CrmView/index.js +4 -4
- package/dist/views/EmailTrackingView/client.js +23 -11
- package/dist/views/EmailTrackingView/index.js +4 -4
- package/dist/views/ImportConversationView/client.js +1 -0
- package/dist/views/ImportConversationView/index.js +4 -4
- package/dist/views/LogsView/client.js +1 -1
- package/dist/views/LogsView/index.js +4 -2
- package/dist/views/NewTicketView/client.js +20 -13
- package/dist/views/NewTicketView/index.js +4 -2
- package/dist/views/PendingEmailsView/client.js +9 -3
- package/dist/views/PendingEmailsView/index.js +4 -4
- package/dist/views/SupportDashboardView/client.js +17 -19
- package/dist/views/SupportDashboardView/index.js +4 -4
- package/dist/views/TicketDetailView/client.js +23 -11
- package/dist/views/TicketDetailView/index.js +4 -4
- package/dist/views/TicketInboxView/client.js +52 -26
- package/dist/views/TicketInboxView/index.js +4 -2
- package/dist/views/TicketingSettingsView/TicketingSettings.module.scss +7 -7
- package/dist/views/TicketingSettingsView/client.js +44 -26
- package/dist/views/TicketingSettingsView/index.js +4 -4
- package/dist/views/TimeDashboardView/client.js +10 -7
- package/dist/views/TimeDashboardView/index.js +4 -4
- package/dist/views/shared/ErrorBoundary.d.ts +36 -1
- package/dist/views/shared/ErrorBoundary.js +59 -28
- package/dist/views/shared/adminTokens.d.ts +15 -5
- package/dist/views/shared/adminTokens.js +23 -7
- package/dist/views/shared/icons.d.ts +49 -0
- package/dist/views/shared/icons.js +92 -0
- package/dist/views/shared/locales/en.json +15 -1
- package/dist/views/shared/locales/fr.json +15 -1
- package/dist/views/shared/viewAccess.d.ts +29 -0
- package/dist/views/shared/viewAccess.js +24 -0
- package/package.json +31 -21
- package/scripts/uninstall-data.mjs +178 -0
- package/scripts/uninstall.mjs +184 -0
- package/src/collections/Tickets.ts +7 -0
- package/src/collections/TimeEntries.ts +11 -3
- package/src/components/RichTextEditor/index.tsx +1 -1
- package/src/components/TicketConversation/RewriteDropdown.tsx +1 -1
- package/src/components/TicketConversation/components/AISummaryPanel.tsx +3 -0
- package/src/components/TicketConversation/components/ActionPanels.tsx +13 -8
- package/src/components/TicketConversation/components/ClientHistory.tsx +1 -0
- package/src/components/TicketConversation/components/CodeBlock.tsx +1 -0
- package/src/components/TicketConversation/components/CodeBlockInserter.tsx +1 -1
- package/src/components/TicketConversation/components/QuickActions.tsx +10 -8
- package/src/components/TicketConversation/components/TicketHeader.tsx +2 -0
- package/src/components/TicketConversation/components/TimeTrackingPanel.tsx +17 -9
- package/src/components/TicketConversation/index.tsx +41 -7
- package/src/endpoints/auth-2fa.ts +53 -8
- package/src/endpoints/capabilities.ts +2 -4
- package/src/endpoints/chatbot.ts +2 -2
- package/src/endpoints/delete-account.ts +191 -47
- package/src/endpoints/export-data.ts +104 -4
- package/src/endpoints/import-conversation.ts +2 -2
- package/src/endpoints/login.ts +13 -3
- package/src/endpoints/purge-logs.ts +6 -15
- package/src/endpoints/push.ts +14 -1
- package/src/endpoints/statuses.ts +17 -0
- package/src/index.ts +2 -0
- package/src/plugin.ts +51 -1
- package/src/portal/LiveChat.tsx +1 -1
- package/src/portal/auth/profile/page.tsx +3 -2
- package/src/portal/icons.tsx +91 -0
- package/src/portal/login/page.tsx +14 -4
- package/src/styles/BillingView.module.scss +11 -11
- package/src/styles/ChatView.module.scss +7 -7
- package/src/styles/CommandPalette.module.scss +1 -1
- package/src/styles/CrmView.module.scss +9 -9
- package/src/styles/EmailTracking.module.scss +6 -6
- package/src/styles/ImportConversation.module.scss +5 -5
- package/src/styles/Logs.module.scss +5 -5
- package/src/styles/NewTicket.module.scss +2 -2
- package/src/styles/PendingEmails.module.scss +13 -13
- package/src/styles/SupportDashboard.module.scss +24 -8
- package/src/styles/TicketDetail.module.scss +20 -20
- package/src/styles/TicketInbox.module.scss +6 -6
- package/src/styles/TicketingSettings.module.scss +10 -10
- package/src/styles/TimeDashboard.module.scss +6 -6
- package/src/styles/_tokens.scss +3 -1
- package/src/types.ts +20 -0
- package/src/utils/push.ts +22 -0
- package/src/utils/rateLimiter.ts +98 -0
- package/src/utils/retention.ts +111 -0
- package/src/utils/slugs.ts +15 -0
- package/src/utils/twoFactorChallenge.ts +6 -1
- package/src/utils/urlSafety.ts +36 -1
- package/src/views/BillingView/client.tsx +17 -8
- package/src/views/BillingView/index.tsx +4 -4
- package/src/views/ChatView/client.tsx +2 -0
- package/src/views/ChatView/index.tsx +4 -4
- package/src/views/CrmView/client.tsx +2 -0
- package/src/views/CrmView/index.tsx +4 -4
- package/src/views/EmailTrackingView/client.tsx +18 -7
- package/src/views/EmailTrackingView/index.tsx +4 -4
- package/src/views/ImportConversationView/client.tsx +1 -0
- package/src/views/ImportConversationView/index.tsx +4 -4
- package/src/views/LogsView/client.tsx +1 -1
- package/src/views/LogsView/index.tsx +4 -2
- package/src/views/NewTicketView/client.tsx +22 -13
- package/src/views/NewTicketView/index.tsx +4 -2
- package/src/views/PendingEmailsView/client.tsx +9 -3
- package/src/views/PendingEmailsView/index.tsx +4 -4
- package/src/views/SupportDashboardView/client.tsx +11 -5
- package/src/views/SupportDashboardView/index.tsx +4 -4
- package/src/views/TicketDetailView/client.tsx +25 -11
- package/src/views/TicketDetailView/index.tsx +4 -4
- package/src/views/TicketInboxView/client.tsx +47 -7
- package/src/views/TicketInboxView/index.tsx +4 -2
- package/src/views/TicketingSettingsView/TicketingSettings.module.scss +7 -7
- package/src/views/TicketingSettingsView/client.tsx +201 -144
- package/src/views/TicketingSettingsView/index.tsx +4 -4
- package/src/views/TimeDashboardView/client.tsx +10 -7
- package/src/views/TimeDashboardView/index.tsx +4 -4
- package/src/views/shared/ErrorBoundary.tsx +95 -31
- package/src/views/shared/adminTokens.ts +28 -11
- package/src/views/shared/icons.tsx +146 -0
- package/src/views/shared/locales/en.json +15 -1
- package/src/views/shared/locales/fr.json +15 -1
- package/src/views/shared/viewAccess.ts +73 -0
- package/src/types/lucide-react.d.ts +0 -42
|
@@ -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
|
+
}
|
package/src/utils/slugs.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
package/src/utils/urlSafety.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
12
|
-
|
|
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
|
-
|
|
12
|
-
|
|
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
|
-
|
|
12
|
-
|
|
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
|
|
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
|
-
|
|
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}>
|
|
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:
|
|
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
|
-
|
|
12
|
-
|
|
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">
|
|
@@ -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
|
-
|
|
12
|
-
|
|
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-
|
|
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
|
-
|
|
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">
|