@open-mercato/ui 0.6.3-develop.3838.1.7f2657a47c → 0.6.3-develop.3857.1.da89d7530c
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/dist/backend/AppShell.js +2 -2
- package/dist/backend/AppShell.js.map +2 -2
- package/dist/backend/CrudForm.js +1 -0
- package/dist/backend/CrudForm.js.map +2 -2
- package/dist/backend/inputs/ComboboxInput.js +104 -26
- package/dist/backend/inputs/ComboboxInput.js.map +2 -2
- package/dist/backend/upgrades/UpgradeActionBanner.js +17 -7
- package/dist/backend/upgrades/UpgradeActionBanner.js.map +2 -2
- package/dist/backend/utils/api.js +17 -14
- package/dist/backend/utils/api.js.map +2 -2
- package/dist/theme/QueryProvider.js +5 -5
- package/dist/theme/QueryProvider.js.map +2 -2
- package/package.json +3 -3
- package/src/backend/AppShell.tsx +3 -2
- package/src/backend/CrudForm.tsx +1 -0
- package/src/backend/__tests__/AppShell.test.tsx +27 -0
- package/src/backend/__tests__/UpgradeActionBanner.test.tsx +146 -0
- package/src/backend/__tests__/nav-utils.test.ts +73 -0
- package/src/backend/inputs/ComboboxInput.tsx +136 -35
- package/src/backend/inputs/__tests__/ComboboxInput.test.tsx +241 -10
- package/src/backend/upgrades/UpgradeActionBanner.tsx +21 -9
- package/src/backend/utils/__tests__/api.test.ts +27 -2
- package/src/backend/utils/api.ts +34 -19
- package/src/theme/QueryProvider.tsx +7 -6
|
@@ -5,10 +5,15 @@ import { Button } from '../../primitives/button'
|
|
|
5
5
|
import { apiCall } from '../utils/apiCall'
|
|
6
6
|
import { flash } from '../FlashMessages'
|
|
7
7
|
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
8
|
+
import { useBackendChrome } from '../BackendChromeProvider'
|
|
9
|
+
import { hasFeature } from '@open-mercato/shared/security/features'
|
|
8
10
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
11
|
+
function upgradeActionsEnabled() {
|
|
12
|
+
return (
|
|
13
|
+
process.env.NEXT_PUBLIC_UPGRADE_ACTIONS_ENABLED === 'true' ||
|
|
14
|
+
process.env.UPGRADE_ACTIONS_ENABLED === 'true'
|
|
15
|
+
)
|
|
16
|
+
}
|
|
12
17
|
|
|
13
18
|
type UpgradeActionPayload = {
|
|
14
19
|
id: string
|
|
@@ -31,23 +36,30 @@ type RunActionResponse = {
|
|
|
31
36
|
error?: string
|
|
32
37
|
}
|
|
33
38
|
|
|
39
|
+
const forbiddenRedirectOptOutHeader = { 'x-om-forbidden-redirect': '0' } as const
|
|
40
|
+
|
|
34
41
|
export function UpgradeActionBanner() {
|
|
35
42
|
const t = useT()
|
|
43
|
+
const { payload, isReady } = useBackendChrome()
|
|
44
|
+
const canManageConfigs = isReady && hasFeature(payload?.grantedFeatures, 'configs.manage')
|
|
36
45
|
const [action, setAction] = React.useState<UpgradeActionPayload | null>(null)
|
|
37
46
|
const [loading, setLoading] = React.useState(false)
|
|
38
47
|
const cancelledRef = React.useRef(false)
|
|
39
48
|
|
|
40
49
|
const loadNextAction = React.useCallback(async () => {
|
|
41
|
-
if (!
|
|
50
|
+
if (!canManageConfigs) return
|
|
51
|
+
if (!upgradeActionsEnabled()) return
|
|
42
52
|
if (typeof window === 'undefined' || typeof fetch === 'undefined') return
|
|
43
|
-
const call = await apiCall<UpgradeActionResponse>('/api/configs/upgrade-actions'
|
|
53
|
+
const call = await apiCall<UpgradeActionResponse>('/api/configs/upgrade-actions', {
|
|
54
|
+
headers: forbiddenRedirectOptOutHeader,
|
|
55
|
+
})
|
|
44
56
|
if (cancelledRef.current) return
|
|
45
57
|
if (!call.ok || !call.result || !Array.isArray(call.result.actions) || !call.result.actions.length) {
|
|
46
58
|
setAction(null)
|
|
47
59
|
return
|
|
48
60
|
}
|
|
49
61
|
setAction(call.result.actions[0]!)
|
|
50
|
-
}, [])
|
|
62
|
+
}, [canManageConfigs])
|
|
51
63
|
|
|
52
64
|
React.useEffect(() => {
|
|
53
65
|
cancelledRef.current = false
|
|
@@ -57,15 +69,15 @@ export function UpgradeActionBanner() {
|
|
|
57
69
|
}
|
|
58
70
|
}, [loadNextAction])
|
|
59
71
|
|
|
60
|
-
if (!upgradeActionsEnabled || !action) return null
|
|
72
|
+
if (!upgradeActionsEnabled() || !canManageConfigs || !action) return null
|
|
61
73
|
|
|
62
74
|
async function handleRun() {
|
|
63
|
-
if (!upgradeActionsEnabled || !action || loading) return
|
|
75
|
+
if (!upgradeActionsEnabled() || !action || loading) return
|
|
64
76
|
setLoading(true)
|
|
65
77
|
try {
|
|
66
78
|
const response = await apiCall<RunActionResponse>('/api/configs/upgrade-actions', {
|
|
67
79
|
method: 'POST',
|
|
68
|
-
headers: { 'Content-Type': 'application/json' },
|
|
80
|
+
headers: { ...forbiddenRedirectOptOutHeader, 'Content-Type': 'application/json' },
|
|
69
81
|
body: JSON.stringify({ actionId: action.id }),
|
|
70
82
|
})
|
|
71
83
|
if (!response.ok) {
|
|
@@ -55,17 +55,42 @@ describe('apiFetch', () => {
|
|
|
55
55
|
jest.restoreAllMocks()
|
|
56
56
|
})
|
|
57
57
|
|
|
58
|
-
it('throws ForbiddenError when backend returns ACL hints', async () => {
|
|
58
|
+
it('throws ForbiddenError and flashes a non-redirecting banner when backend returns ACL hints', async () => {
|
|
59
59
|
;(window as unknown as Record<string, unknown>).__omOriginalFetch = jest.fn(async () =>
|
|
60
60
|
createMockResponse(403, {
|
|
61
61
|
error: 'Forbidden',
|
|
62
62
|
requiredRoles: ['Admin'],
|
|
63
63
|
}),
|
|
64
64
|
)
|
|
65
|
+
const initialPath = window.location.pathname
|
|
65
66
|
|
|
66
67
|
await expect(apiFetch('/api/private')).rejects.toBeInstanceOf(ForbiddenError)
|
|
67
68
|
expect(flash).toHaveBeenCalledWith(
|
|
68
|
-
'
|
|
69
|
+
expect.stringContaining('Access denied'),
|
|
70
|
+
'warning',
|
|
71
|
+
)
|
|
72
|
+
expect(flash).toHaveBeenCalledWith(
|
|
73
|
+
expect.stringContaining('Admin'),
|
|
74
|
+
'warning',
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
// Regression for GH #2070: authenticated 403 must not redirect to /login.
|
|
78
|
+
jest.advanceTimersByTime(1000)
|
|
79
|
+
expect(window.location.pathname).toBe(initialPath)
|
|
80
|
+
expect(window.location.pathname).not.toContain('/login')
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('includes the missing feature name in the flash so the user sees the actual permission required', async () => {
|
|
84
|
+
;(window as unknown as Record<string, unknown>).__omOriginalFetch = jest.fn(async () =>
|
|
85
|
+
createMockResponse(403, {
|
|
86
|
+
error: 'Forbidden',
|
|
87
|
+
requiredFeatures: ['sales.channels.manage'],
|
|
88
|
+
}),
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
await expect(apiFetch('/api/sales/channels')).rejects.toBeInstanceOf(ForbiddenError)
|
|
92
|
+
expect(flash).toHaveBeenCalledWith(
|
|
93
|
+
expect.stringContaining('sales.channels.manage'),
|
|
69
94
|
'warning',
|
|
70
95
|
)
|
|
71
96
|
})
|
package/src/backend/utils/api.ts
CHANGED
|
@@ -85,32 +85,47 @@ export function setAuthRedirectConfig(cfg: { defaultForbiddenRoles?: readonly st
|
|
|
85
85
|
}
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
-
|
|
88
|
+
function formatForbiddenAccessMessage(options?: { requiredRoles?: string[] | null; requiredFeatures?: string[] | null }): string {
|
|
89
|
+
const features = options?.requiredFeatures?.filter(Boolean) ?? []
|
|
90
|
+
const roles = options?.requiredRoles?.filter(Boolean) ?? []
|
|
91
|
+
const effectiveRoles = roles.length ? roles : DEFAULT_FORBIDDEN_ROLES.filter(Boolean)
|
|
92
|
+
if (features.length) {
|
|
93
|
+
return `Access denied: you are missing the required permission "${features.join(', ')}". Contact your administrator.`
|
|
94
|
+
}
|
|
95
|
+
if (effectiveRoles.length) {
|
|
96
|
+
return `Access denied: this area requires the role "${effectiveRoles.join(', ')}". Contact your administrator.`
|
|
97
|
+
}
|
|
98
|
+
return 'Access denied: you do not have permission to perform this action.'
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Signal a forbidden access attempt for an authenticated user via a flash banner.
|
|
103
|
+
*
|
|
104
|
+
* Authenticated 403 responses must never redirect to `/login` — that creates an
|
|
105
|
+
* infinite loop because the login page detects the active session and bounces
|
|
106
|
+
* the user back to the failing destination (see GH #2070). Pages that need an
|
|
107
|
+
* inline banner should catch `ForbiddenError` and render `AccessDeniedMessage`
|
|
108
|
+
* from `@open-mercato/ui/backend/detail`.
|
|
109
|
+
*/
|
|
110
|
+
export function notifyForbiddenAccess(options?: { requiredRoles?: string[] | null; requiredFeatures?: string[] | null }) {
|
|
89
111
|
if (typeof window === 'undefined') return
|
|
90
|
-
|
|
91
|
-
// Portal routes have their own customer auth — never redirect to staff login
|
|
112
|
+
// Portal routes have their own customer auth — keep the existing no-op contract.
|
|
92
113
|
if (/\/[^/]+\/portal(\/|$)/.test(window.location.pathname)) return
|
|
93
114
|
try {
|
|
94
|
-
|
|
95
|
-
const features = options?.requiredFeatures?.filter(Boolean) ?? []
|
|
96
|
-
const roles = options?.requiredRoles?.filter(Boolean) ?? []
|
|
97
|
-
const fallbackRoles = DEFAULT_FORBIDDEN_ROLES.filter(Boolean)
|
|
98
|
-
const effectiveRoles = roles.length ? roles : fallbackRoles
|
|
99
|
-
const query = features.length
|
|
100
|
-
? `requireFeature=${encodeURIComponent(features.join(','))}`
|
|
101
|
-
: effectiveRoles.length
|
|
102
|
-
? `requireRole=${encodeURIComponent(effectiveRoles.map(String).join(','))}`
|
|
103
|
-
: ''
|
|
104
|
-
const url = query
|
|
105
|
-
? `/login?${query}&redirect=${encodeURIComponent(current)}`
|
|
106
|
-
: `/login?redirect=${encodeURIComponent(current)}`
|
|
107
|
-
flash('Insufficient permissions. Redirecting to login…', 'warning')
|
|
108
|
-
setTimeout(() => { window.location.href = url }, 60)
|
|
115
|
+
flash(formatForbiddenAccessMessage(options), 'warning')
|
|
109
116
|
} catch {
|
|
110
117
|
// no-op
|
|
111
118
|
}
|
|
112
119
|
}
|
|
113
120
|
|
|
121
|
+
/**
|
|
122
|
+
* @deprecated Renamed to {@link notifyForbiddenAccess}. The previous name
|
|
123
|
+
* implied a `/login` redirect that no longer happens (see GH #2070). Kept as an
|
|
124
|
+
* exported alias for one minor version so third-party module imports keep
|
|
125
|
+
* building; update imports to `notifyForbiddenAccess`.
|
|
126
|
+
*/
|
|
127
|
+
export const redirectToForbiddenLogin = notifyForbiddenAccess
|
|
128
|
+
|
|
114
129
|
export async function apiFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
|
|
115
130
|
type FetchType = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
|
|
116
131
|
const originalFetch =
|
|
@@ -191,7 +206,7 @@ export async function apiFetch(input: RequestInfo | URL, init?: RequestInit): Pr
|
|
|
191
206
|
} catch {}
|
|
192
207
|
const hasAclHints = Boolean((roles && roles.length) || (features && features.length))
|
|
193
208
|
if (hasAclHints) {
|
|
194
|
-
|
|
209
|
+
notifyForbiddenAccess({ requiredRoles: roles, requiredFeatures: features })
|
|
195
210
|
}
|
|
196
211
|
let msg = 'Forbidden'
|
|
197
212
|
if (aclData && typeof aclData === 'object') {
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
import * as React from 'react'
|
|
3
3
|
import { MutationCache, QueryCache, QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|
4
|
-
import { redirectToSessionRefresh,
|
|
4
|
+
import { redirectToSessionRefresh, notifyForbiddenAccess, UnauthorizedError, ForbiddenError, apiFetch, setAuthRedirectConfig } from '../backend/utils/api'
|
|
5
5
|
|
|
6
|
-
// Ensure global fetch calls also
|
|
6
|
+
// Ensure global fetch calls also flow through apiFetch so 401 session-refresh
|
|
7
|
+
// and 403 access-denied flash banners fire consistently.
|
|
7
8
|
function ensureGlobalFetchInterception() {
|
|
8
9
|
if (typeof window === 'undefined') return
|
|
9
10
|
const w = window as any
|
|
@@ -17,18 +18,18 @@ const client = new QueryClient({
|
|
|
17
18
|
queryCache: new QueryCache({
|
|
18
19
|
onError: (error) => {
|
|
19
20
|
if (error instanceof UnauthorizedError) redirectToSessionRefresh()
|
|
20
|
-
else if (error instanceof ForbiddenError)
|
|
21
|
+
else if (error instanceof ForbiddenError) notifyForbiddenAccess()
|
|
21
22
|
// As a fallback, try to detect common cases
|
|
22
23
|
else if ((error as any)?.status === 401) redirectToSessionRefresh()
|
|
23
|
-
else if ((error as any)?.status === 403)
|
|
24
|
+
else if ((error as any)?.status === 403) notifyForbiddenAccess()
|
|
24
25
|
},
|
|
25
26
|
}),
|
|
26
27
|
mutationCache: new MutationCache({
|
|
27
28
|
onError: (error) => {
|
|
28
29
|
if (error instanceof UnauthorizedError) redirectToSessionRefresh()
|
|
29
|
-
else if (error instanceof ForbiddenError)
|
|
30
|
+
else if (error instanceof ForbiddenError) notifyForbiddenAccess()
|
|
30
31
|
else if ((error as any)?.status === 401) redirectToSessionRefresh()
|
|
31
|
-
else if ((error as any)?.status === 403)
|
|
32
|
+
else if ((error as any)?.status === 403) notifyForbiddenAccess()
|
|
32
33
|
},
|
|
33
34
|
}),
|
|
34
35
|
})
|