@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.
- package/README.md +24 -14
- package/dist/index.cjs +115 -13
- package/dist/index.d.cts +20 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.js +115 -13
- package/dist/utils/db.d.ts +34 -0
- package/dist/utils/readSettings.d.ts +90 -0
- package/dist/views/BillingView/index.js +4 -4
- package/dist/views/ChatView/index.js +4 -4
- package/dist/views/CrmView/index.js +4 -4
- package/dist/views/EmailTrackingView/index.js +4 -4
- package/dist/views/ImportConversationView/index.js +4 -4
- package/dist/views/LogsView/index.js +4 -2
- package/dist/views/NewTicketView/index.js +4 -2
- package/dist/views/PendingEmailsView/index.js +4 -4
- package/dist/views/SupportDashboardView/index.js +4 -4
- package/dist/views/TicketDetailView/index.js +4 -4
- package/dist/views/TicketInboxView/index.js +4 -2
- package/dist/views/TicketingSettingsView/index.js +4 -4
- package/dist/views/TimeDashboardView/index.js +4 -4
- package/dist/views/shared/viewAccess.d.ts +29 -0
- package/dist/views/shared/viewAccess.js +24 -0
- package/package.json +26 -20
- 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/import-conversation.ts +2 -2
- package/src/endpoints/login.ts +13 -3
- package/src/endpoints/push.ts +14 -1
- package/src/endpoints/statuses.ts +17 -0
- package/src/portal/login/page.tsx +14 -4
- package/src/utils/push.ts +22 -0
- package/src/utils/rateLimiter.ts +98 -0
- package/src/utils/twoFactorChallenge.ts +6 -1
- package/src/utils/urlSafety.ts +36 -1
- package/src/views/BillingView/index.tsx +4 -4
- package/src/views/ChatView/index.tsx +4 -4
- package/src/views/CrmView/index.tsx +4 -4
- package/src/views/EmailTrackingView/index.tsx +4 -4
- package/src/views/ImportConversationView/index.tsx +4 -4
- package/src/views/LogsView/index.tsx +4 -2
- package/src/views/NewTicketView/index.tsx +4 -2
- package/src/views/PendingEmailsView/index.tsx +4 -4
- package/src/views/SupportDashboardView/index.tsx +4 -4
- package/src/views/TicketDetailView/index.tsx +4 -4
- package/src/views/TicketInboxView/index.tsx +4 -2
- package/src/views/TicketingSettingsView/index.tsx +4 -4
- package/src/views/TimeDashboardView/index.tsx +4 -4
- package/src/views/shared/viewAccess.ts +73 -0
package/src/utils/rateLimiter.ts
CHANGED
|
@@ -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
|
-
|
|
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,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">
|
|
@@ -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">
|
|
@@ -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">
|
|
@@ -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">
|
|
@@ -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">
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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 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
|
-
|
|
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 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
|
-
|
|
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 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
|
-
|
|
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
|
-
|
|
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 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
|
-
|
|
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 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
|
+
}
|