@consilioweb/payload-support 4.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 (49) hide show
  1. package/README.md +24 -14
  2. package/dist/index.cjs +115 -13
  3. package/dist/index.d.cts +20 -0
  4. package/dist/index.d.ts +20 -0
  5. package/dist/index.js +115 -13
  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/endpoints/auth-2fa.ts +53 -8
  25. package/src/endpoints/capabilities.ts +2 -4
  26. package/src/endpoints/chatbot.ts +2 -2
  27. package/src/endpoints/import-conversation.ts +2 -2
  28. package/src/endpoints/login.ts +13 -3
  29. package/src/endpoints/push.ts +14 -1
  30. package/src/endpoints/statuses.ts +17 -0
  31. package/src/portal/login/page.tsx +14 -4
  32. package/src/utils/push.ts +22 -0
  33. package/src/utils/rateLimiter.ts +98 -0
  34. package/src/utils/twoFactorChallenge.ts +6 -1
  35. package/src/utils/urlSafety.ts +36 -1
  36. package/src/views/BillingView/index.tsx +4 -4
  37. package/src/views/ChatView/index.tsx +4 -4
  38. package/src/views/CrmView/index.tsx +4 -4
  39. package/src/views/EmailTrackingView/index.tsx +4 -4
  40. package/src/views/ImportConversationView/index.tsx +4 -4
  41. package/src/views/LogsView/index.tsx +4 -2
  42. package/src/views/NewTicketView/index.tsx +4 -2
  43. package/src/views/PendingEmailsView/index.tsx +4 -4
  44. package/src/views/SupportDashboardView/index.tsx +4 -4
  45. package/src/views/TicketDetailView/index.tsx +4 -4
  46. package/src/views/TicketInboxView/index.tsx +4 -2
  47. package/src/views/TicketingSettingsView/index.tsx +4 -4
  48. package/src/views/TimeDashboardView/index.tsx +4 -4
  49. package/src/views/shared/viewAccess.ts +73 -0
@@ -8,13 +8,59 @@ export interface RateLimitStore {
8
8
  reset(key: string, context?: unknown): Promise<void>
9
9
  }
10
10
 
11
+ /**
12
+ * Hard ceiling on the number of distinct keys held at once, all endpoints
13
+ * combined. Sized for a busy install (thousands of client IPs inside one
14
+ * 15-minute window) while capping the map at a few hundred kilobytes.
15
+ */
16
+ export const MAX_MEMORY_RATE_LIMIT_KEYS = 10_000
17
+
11
18
  /**
12
19
  * Process-local fallback store. Applications running more than one process
13
20
  * should provide a persistent RateLimitStore through the plugin options.
21
+ *
22
+ * BOUNDED, because the keys come from anonymous HTTP traffic: `/support/login`
23
+ * and `/support/chatbot` key their limiter on the client IP, and the IP is read
24
+ * from `x-forwarded-for`. A caller rotating that header wrote one PERMANENT
25
+ * entry per value into a map that had no ceiling, no expiry sweep and no
26
+ * eviction — only `reset()` ever removed anything, and the login path never
27
+ * calls it. Worse, every fresh key opened a fresh window, so the limiter did
28
+ * not even slow down the flood that was exhausting it.
29
+ *
30
+ * Two independent bounds, the same pair `endpoints/typing.ts` already applies
31
+ * to its own module-level map: `clientIpRateKey` caps the SIZE of a key, the
32
+ * ceiling below caps their NUMBER.
14
33
  */
15
34
  export class MemoryRateLimitStore implements RateLimitStore {
16
35
  private readonly entries = new Map<string, RateLimitEntry>()
17
36
 
37
+ constructor(private readonly maxKeys: number = MAX_MEMORY_RATE_LIMIT_KEYS) {}
38
+
39
+ /** Distinct keys currently held. Exposed so the ceiling can be asserted. */
40
+ get size(): number {
41
+ return this.entries.size
42
+ }
43
+
44
+ /** Reclaims every window that has already closed. */
45
+ private sweepExpired(now: number): void {
46
+ for (const [key, entry] of this.entries) {
47
+ if (now > entry.resetAt) this.entries.delete(key)
48
+ }
49
+ }
50
+
51
+ /** Soonest-closing window first, so the ceiling drops the least useful entry. */
52
+ private evictOldest(): void {
53
+ let oldestKey: string | null = null
54
+ let oldestResetAt = Infinity
55
+ for (const [key, entry] of this.entries) {
56
+ if (entry.resetAt < oldestResetAt) {
57
+ oldestResetAt = entry.resetAt
58
+ oldestKey = key
59
+ }
60
+ }
61
+ if (oldestKey !== null) this.entries.delete(oldestKey)
62
+ }
63
+
18
64
  async increment(key: string, windowMs: number): Promise<RateLimitEntry> {
19
65
  const now = Date.now()
20
66
  const current = this.entries.get(key)
@@ -22,6 +68,12 @@ export class MemoryRateLimitStore implements RateLimitStore {
22
68
  ? { count: 1, resetAt: now + windowMs }
23
69
  : { ...current, count: current.count + 1 }
24
70
 
71
+ // Only a key that is not already held can grow the map.
72
+ if (!current && this.entries.size >= this.maxKeys) {
73
+ this.sweepExpired(now)
74
+ if (this.entries.size >= this.maxKeys) this.evictOldest()
75
+ }
76
+
25
77
  this.entries.set(key, next)
26
78
  return next
27
79
  }
@@ -134,6 +186,52 @@ export function principalRateKey(
134
186
  return `${collection}:${String(user.id)}`
135
187
  }
136
188
 
189
+ /**
190
+ * Longest textual IPv6 address, `xxxx:` * 7 + an embedded IPv4 literal.
191
+ * Nothing legitimate is longer; the cap is what makes the key bounded.
192
+ */
193
+ const MAX_IP_KEY_LENGTH = 45
194
+ /** Dotted-quad, or the hex/`::`/zone alphabet of an IPv6 literal. Shape only. */
195
+ const IPV4_PATTERN = /^(?:\d{1,3}\.){3}\d{1,3}$/
196
+ const IPV6_PATTERN = /^[0-9a-fA-F:.%]+$/
197
+
198
+ /**
199
+ * Identity part of a rate-limit key for an ANONYMOUS caller.
200
+ *
201
+ * `x-forwarded-for` is attacker-controlled — the note in `endpoints/login.ts`
202
+ * has always said so, and Payload's account lock is what actually stops a
203
+ * brute-force. But the header was also used RAW as the limiter key, and that is
204
+ * a second, distinct problem: a key is a stored object. A caller rotating the
205
+ * header minted one unbounded, never-reclaimed entry per value; a value can be
206
+ * most of Node's 16 KB header budget. This validates the SHAPE and caps the
207
+ * LENGTH, so a forged header can still pick a bucket but can no longer invent
208
+ * an unbounded number of them, nor make any single one large.
209
+ *
210
+ * Anything that is not an IP literal falls back to the shared `unknown` bucket.
211
+ * That is the fail-closed direction: an install with no proxy already puts
212
+ * every caller there, and one behind a proxy never lands there legitimately.
213
+ */
214
+ export function clientIpRateKey(req: { headers: { get(name: string): string | null } }): string {
215
+ const candidate = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
216
+ || req.headers.get('x-real-ip')?.trim()
217
+ || ''
218
+ return normalizeIpKey(candidate)
219
+ }
220
+
221
+ /** Exposed separately so a caller holding an already-extracted address can reuse it. */
222
+ export function normalizeIpKey(candidate: string): string {
223
+ if (!candidate || candidate.length > MAX_IP_KEY_LENGTH) return 'unknown'
224
+ const host = candidate.startsWith('[') && candidate.endsWith(']')
225
+ ? candidate.slice(1, -1)
226
+ : candidate
227
+ if (!host) return 'unknown'
228
+ if (IPV4_PATTERN.test(host)) {
229
+ return host.split('.').every((octet) => Number(octet) <= 255) ? host : 'unknown'
230
+ }
231
+ if (host.includes(':') && IPV6_PATTERN.test(host)) return host.toLowerCase()
232
+ return 'unknown'
233
+ }
234
+
137
235
  export class RateLimiter {
138
236
  private readonly store: RateLimitStore
139
237
  private readonly prefix: string
@@ -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,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">
@@ -0,0 +1,73 @@
1
+ import type { AdminViewServerProps } from 'payload'
2
+ import { formatAdminURL } from 'payload/shared'
3
+ import { SUPPORT_STAFF_SLUG_CONFIG_KEY } from '../../utils/readSettings.js'
4
+
5
+ /**
6
+ * Where a custom admin view must send a caller it refuses, or `null` to render.
7
+ *
8
+ * Payload does NOT gate custom admin views. `RootPage` skips its own
9
+ * `canAccessAdmin` redirect as soon as `isCustomAdminView()` matches, and that
10
+ * helper only compares the request path against the registered `view.path` —
11
+ * it reads no visibility flag, despite what its docblock claims. Authorising a
12
+ * custom view is therefore the view's own job, and every view registered by
13
+ * this plugin sits at a custom path (`/support/inbox`, `/support/ticket`, …).
14
+ *
15
+ * `!req.user` alone is not that check. A single `payload-token` cookie serves
16
+ * every auth collection of the host app, so an ordinary front-office account —
17
+ * a `customers`, `members` or `subscribers` signup — carries one on `/admin`
18
+ * routes too. Such a caller reached `DefaultTemplate` and received the admin
19
+ * chrome together with the client config Payload builds for any authenticated
20
+ * request: the field schema of every collection and global, `admin.hidden`
21
+ * ones included, plus an unfiltered `visibleEntities`.
22
+ *
23
+ * The ticket data itself never travelled — the client components fetch through
24
+ * `/api/support/*`, which `requireAdmin` has guarded all along — but the shape
25
+ * of the whole CMS did.
26
+ *
27
+ * The gate mirrors `requireAdmin` (utils/auth.ts): membership of the staff
28
+ * collection. It additionally honours `canAccessAdmin`, so an account the host
29
+ * disabled through its own `access.admin` is refused here too, and it fails
30
+ * closed on anything it cannot resolve.
31
+ */
32
+ export function supportViewRedirectTarget(
33
+ initPageResult: AdminViewServerProps['initPageResult'],
34
+ ): string | null {
35
+ const req = initPageResult?.req
36
+ const config = req?.payload?.config
37
+ const adminRoute = config?.routes?.admin ?? '/admin'
38
+ const routes = config?.admin?.routes
39
+
40
+ const to = (route: string | undefined, fallback: string) =>
41
+ formatAdminURL({ adminRoute, path: (route ?? fallback) as `/${string}` })
42
+
43
+ if (!req?.user) return to(routes?.login, '/login')
44
+
45
+ // `sanitizePermissions` deletes the key when it is false, so an explicit
46
+ // `false` is a denial while `undefined` simply means "not computed here".
47
+ if (initPageResult?.permissions?.canAccessAdmin === false) {
48
+ return to(routes?.unauthorized, '/unauthorized')
49
+ }
50
+
51
+ // Same source of truth as the write path: the slug `plugin.ts` publishes on
52
+ // `config.custom`, NOT `config.admin.user` — Payload defaults the latter to
53
+ // the app's first auth collection, which on a host declaring a front office
54
+ // first is precisely the collection we are trying to keep out.
55
+ //
56
+ // `resolveStaffPrefSlug` is deliberately NOT used here. Its last-resort
57
+ // fallback is the literal `'users'`, which is the right call on a read path
58
+ // (a wrong scope returns no settings) and the wrong one on an authorisation
59
+ // path: it would admit whoever happens to sit in a collection named `users`
60
+ // on a host where neither source resolved. A gate with nothing to compare
61
+ // against refuses.
62
+ const custom = config?.custom as Record<string, unknown> | undefined
63
+ const registered = custom?.[SUPPORT_STAFF_SLUG_CONFIG_KEY]
64
+ const staffSlug =
65
+ (typeof registered === 'string' && registered) || config?.admin?.user || null
66
+
67
+ const collection = (req.user as { collection?: string }).collection
68
+ if (!staffSlug || !collection || collection !== staffSlug) {
69
+ return to(routes?.unauthorized, '/unauthorized')
70
+ }
71
+
72
+ return null
73
+ }