@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.
@@ -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
- const upgradeActionsEnabled =
10
- process.env.NEXT_PUBLIC_UPGRADE_ACTIONS_ENABLED === 'true' ||
11
- process.env.UPGRADE_ACTIONS_ENABLED === 'true'
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 (!upgradeActionsEnabled) return
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
- 'Insufficient permissions. Redirecting to login…',
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
  })
@@ -85,32 +85,47 @@ export function setAuthRedirectConfig(cfg: { defaultForbiddenRoles?: readonly st
85
85
  }
86
86
  }
87
87
 
88
- export function redirectToForbiddenLogin(options?: { requiredRoles?: string[] | null; requiredFeatures?: string[] | null }) {
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
- if (window.location.pathname.startsWith('/login')) return
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
- const current = window.location.pathname + window.location.search
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
- redirectToForbiddenLogin({ requiredRoles: roles, requiredFeatures: features })
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, redirectToForbiddenLogin, UnauthorizedError, ForbiddenError, apiFetch, setAuthRedirectConfig } from '../backend/utils/api'
4
+ import { redirectToSessionRefresh, notifyForbiddenAccess, UnauthorizedError, ForbiddenError, apiFetch, setAuthRedirectConfig } from '../backend/utils/api'
5
5
 
6
- // Ensure global fetch calls also respect our redirect-on-401/403 policy.
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) redirectToForbiddenLogin()
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) redirectToForbiddenLogin()
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) redirectToForbiddenLogin()
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) redirectToForbiddenLogin()
32
+ else if ((error as any)?.status === 403) notifyForbiddenAccess()
32
33
  },
33
34
  }),
34
35
  })