@consilioweb/payload-support 3.0.0 → 5.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 (77) hide show
  1. package/README.md +58 -14
  2. package/dist/index.cjs +673 -107
  3. package/dist/index.d.cts +40 -5
  4. package/dist/index.d.ts +40 -5
  5. package/dist/index.js +673 -107
  6. package/dist/utils/db.d.ts +34 -0
  7. package/dist/utils/readSettings.d.ts +90 -0
  8. package/dist/views/BillingView/index.js +4 -4
  9. package/dist/views/ChatView/index.js +4 -4
  10. package/dist/views/CrmView/index.js +4 -4
  11. package/dist/views/EmailTrackingView/index.js +4 -4
  12. package/dist/views/ImportConversationView/index.js +4 -4
  13. package/dist/views/LogsView/index.js +4 -2
  14. package/dist/views/NewTicketView/index.js +4 -2
  15. package/dist/views/PendingEmailsView/index.js +4 -4
  16. package/dist/views/SupportDashboardView/index.js +4 -4
  17. package/dist/views/TicketDetailView/index.js +4 -4
  18. package/dist/views/TicketInboxView/index.js +4 -2
  19. package/dist/views/TicketingSettingsView/index.js +4 -4
  20. package/dist/views/TimeDashboardView/index.js +4 -4
  21. package/dist/views/shared/viewAccess.d.ts +29 -0
  22. package/dist/views/shared/viewAccess.js +24 -0
  23. package/package.json +26 -20
  24. package/src/collections/ChatMessages.ts +59 -2
  25. package/src/collections/ClientSummaries.ts +10 -4
  26. package/src/collections/TicketMessages.ts +4 -1
  27. package/src/collections/WebhookEndpoints.ts +44 -2
  28. package/src/endpoints/admin-chat.ts +3 -3
  29. package/src/endpoints/ai-agent.ts +3 -3
  30. package/src/endpoints/ai.ts +3 -3
  31. package/src/endpoints/auth-2fa.ts +68 -11
  32. package/src/endpoints/capabilities.ts +7 -9
  33. package/src/endpoints/chat.ts +7 -5
  34. package/src/endpoints/chatbot.ts +50 -4
  35. package/src/endpoints/client-intelligence.ts +4 -4
  36. package/src/endpoints/email-stats.ts +19 -3
  37. package/src/endpoints/import-conversation.ts +3 -3
  38. package/src/endpoints/index.ts +1 -1
  39. package/src/endpoints/invite-collaborator.ts +29 -3
  40. package/src/endpoints/login.ts +28 -5
  41. package/src/endpoints/oauth-google.ts +130 -8
  42. package/src/endpoints/push.ts +14 -1
  43. package/src/endpoints/resend-notification.ts +3 -3
  44. package/src/endpoints/send-reminder.ts +3 -3
  45. package/src/endpoints/signature.ts +9 -2
  46. package/src/endpoints/statuses.ts +17 -0
  47. package/src/endpoints/ticket-synthesis.ts +3 -3
  48. package/src/endpoints/transfer-ticket.ts +28 -3
  49. package/src/endpoints/typing.ts +117 -14
  50. package/src/endpoints/user-prefs.ts +5 -2
  51. package/src/plugin.ts +12 -0
  52. package/src/portal/auth/layout.tsx +19 -1
  53. package/src/portal/auth/tickets/detail/MessageBody.tsx +88 -0
  54. package/src/portal/auth/tickets/detail/page.tsx +2 -6
  55. package/src/portal/login/page.tsx +23 -5
  56. package/src/utils/fireWebhooks.ts +4 -1
  57. package/src/utils/push.ts +22 -0
  58. package/src/utils/rateLimiter.ts +136 -4
  59. package/src/utils/readSettings.ts +124 -14
  60. package/src/utils/ticketAccess.ts +16 -1
  61. package/src/utils/twoFactorChallenge.ts +85 -0
  62. package/src/utils/urlSafety.ts +265 -0
  63. package/src/utils/webhookDispatcher.ts +5 -1
  64. package/src/views/BillingView/index.tsx +4 -4
  65. package/src/views/ChatView/index.tsx +4 -4
  66. package/src/views/CrmView/index.tsx +4 -4
  67. package/src/views/EmailTrackingView/index.tsx +4 -4
  68. package/src/views/ImportConversationView/index.tsx +4 -4
  69. package/src/views/LogsView/index.tsx +4 -2
  70. package/src/views/NewTicketView/index.tsx +4 -2
  71. package/src/views/PendingEmailsView/index.tsx +4 -4
  72. package/src/views/SupportDashboardView/index.tsx +4 -4
  73. package/src/views/TicketDetailView/index.tsx +4 -4
  74. package/src/views/TicketInboxView/index.tsx +4 -2
  75. package/src/views/TicketingSettingsView/index.tsx +4 -4
  76. package/src/views/TimeDashboardView/index.tsx +4 -4
  77. package/src/views/shared/viewAccess.ts +73 -0
@@ -0,0 +1,265 @@
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: outbound webhook endpoints, and Web Push subscription 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
+ * 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
+
182
+ /**
183
+ * Resolve the name and reject unless EVERY resolved address is public.
184
+ *
185
+ * FAIL-CLOSED on anything that is not a clean answer. The previous version
186
+ * returned `true` on its own timeout, on an empty answer and on every error, on
187
+ * the theory that "`fetch` would fail the same way". A slow resolver is not a
188
+ * failing resolver: an attacker-run authoritative server answering just after
189
+ * the deadline got this guard to wave the request through, and `fetch` then did
190
+ * its OWN resolution — no deadline — and reached 127.0.0.1. So a timeout, a
191
+ * temporary resolver error (EAI_AGAIN/EAI_FAIL) and an empty answer all block.
192
+ *
193
+ * The ONE tolerated case is a definitive "this name does not exist"
194
+ * (ENOTFOUND / ENODATA): there is no address for `fetch` to reach either, so
195
+ * blocking would only swap one failure message for another.
196
+ *
197
+ * The deadline is generous on purpose — with fail-closed semantics it now costs
198
+ * a legitimate delivery when it fires, and a resolver needing more than 5s is
199
+ * broken, not slow. It still bounds the delivery: the `fetch` that follows has
200
+ * its own 10s abort.
201
+ */
202
+ const LOOKUP_TIMEOUT_MS = 5000
203
+ const LOOKUP_TIMED_OUT = Symbol('lookup-timed-out')
204
+ /** Definitive "no such host" — nothing for `fetch` to connect to either. */
205
+ const NONEXISTENT_HOST_CODES = new Set(['ENOTFOUND', 'ENODATA', 'NOTFOUND'])
206
+
207
+ export async function assertPublicHost(hostname: string): Promise<boolean> {
208
+ const host = normalizeHost(hostname)
209
+ if (isBlockedHost(host)) return false
210
+ if (parseIPv4(host) || host.includes(':')) return true // already a literal, checked above
211
+ try {
212
+ // Bounded: a stalled resolver must not hold a webhook delivery open.
213
+ const addresses = await Promise.race([
214
+ lookup(host, { all: true }),
215
+ new Promise<typeof LOOKUP_TIMED_OUT>((resolve) =>
216
+ setTimeout(() => resolve(LOOKUP_TIMED_OUT), LOOKUP_TIMEOUT_MS).unref?.(),
217
+ ),
218
+ ])
219
+ if (addresses === LOOKUP_TIMED_OUT) return false
220
+ // `[].every()` is `true`: an empty answer must not read as "all public".
221
+ if (!Array.isArray(addresses) || addresses.length === 0) return false
222
+ return addresses.every((entry) => !isBlockedHost(entry.address))
223
+ } catch (error) {
224
+ const code = (error as { code?: string } | null)?.code
225
+ return typeof code === 'string' && NONEXISTENT_HOST_CODES.has(code)
226
+ }
227
+ }
228
+
229
+ export class BlockedRequestError extends Error {
230
+ constructor(public readonly reason: NonNullable<UrlValidationResult['reason']> | 'too_many_redirects') {
231
+ super(`Blocked outbound request: ${reason}`)
232
+ this.name = 'BlockedRequestError'
233
+ }
234
+ }
235
+
236
+ const MAX_REDIRECTS = 3
237
+
238
+ /**
239
+ * `fetch` with SSRF guards on the initial URL AND on every redirect target.
240
+ *
241
+ * `redirect: 'manual'` is the point: the default policy follows 3xx inside
242
+ * undici, where no guard of ours can see the new target.
243
+ */
244
+ export async function safeFetch(rawUrl: string, init: RequestInit): Promise<Response> {
245
+ let current = rawUrl
246
+ let body: BodyInit | null | undefined = init.body
247
+
248
+ for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
249
+ const validation = validateWebhookUrl(current)
250
+ if (!validation.ok || !validation.url) throw new BlockedRequestError(validation.reason || 'invalid_url')
251
+ if (!(await assertPublicHost(validation.url.hostname))) throw new BlockedRequestError('private_host')
252
+
253
+ const response = await fetch(current, { ...init, body, redirect: 'manual' })
254
+ if (response.status < 300 || response.status > 399) return response
255
+
256
+ const location = response.headers.get('location')
257
+ if (!location) return response
258
+
259
+ current = new URL(location, current).toString()
260
+ // 303 (and, in practice, 301/302 on a POST) turns the follow-up into a GET.
261
+ if (response.status === 303) body = undefined
262
+ }
263
+
264
+ throw new BlockedRequestError('too_many_redirects')
265
+ }
@@ -2,6 +2,7 @@ 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
  /**
7
8
  * Outbound webhook events. Must stay in sync with the `events` options of the
@@ -90,7 +91,10 @@ async function _sendToEndpoint(
90
91
  headers['X-Webhook-Signature'] = signature
91
92
  }
92
93
 
93
- const response = await fetch(endpoint.url, {
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, {
94
98
  method: 'POST',
95
99
  headers,
96
100
  body,
@@ -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">
@@ -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">
@@ -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">
@@ -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">
@@ -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">
@@ -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">
@@ -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 { NewTicketClient } from './client'
7
8
 
8
9
  export const NewTicketView: 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 NewTicketView: 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="NewTicketView">
@@ -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 { PendingEmailsClient } from './client'
@@ -8,9 +9,8 @@ import { PendingEmailsClient } from './client'
8
9
  export const PendingEmailsView: 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 PendingEmailsView: 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="PendingEmailsView">
@@ -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 { SupportDashboardClient } from './client'
@@ -8,9 +9,8 @@ import { SupportDashboardClient } from './client'
8
9
  export const SupportDashboardView: 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 SupportDashboardView: React.FC<AdminViewServerProps> = ({ initPageR
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="SupportDashboardView">
@@ -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 { TicketDetailClient } from './client'
@@ -8,9 +9,8 @@ import { TicketDetailClient } from './client'
8
9
  export const TicketDetailView: 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 TicketDetailView: React.FC<AdminViewServerProps> = ({ initPageResul
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="TicketDetailView">
@@ -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 { TicketInboxClient } from './client'
7
8
 
8
9
  export const TicketInboxView: 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 TicketInboxView: 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="TicketInboxView">
@@ -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 { TicketingSettingsClient } from './client'
@@ -8,9 +9,8 @@ import { TicketingSettingsClient } from './client'
8
9
  export const TicketingSettingsView: 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 TicketingSettingsView: React.FC<AdminViewServerProps> = ({ initPage
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="TicketingSettingsView">
@@ -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 { TimeDashboardClient } from './client'
@@ -8,9 +9,8 @@ import { TimeDashboardClient } from './client'
8
9
  export const TimeDashboardView: 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 TimeDashboardView: 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="TimeDashboardView">