@faststore/core 4.8.1-dev.0 → 4.8.1-dev.2
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/.turbo/turbo-generate.log +3 -3
- package/.turbo/turbo-test.log +146 -105
- package/@generated/gql.ts +3 -3
- package/@generated/graphql.ts +4 -2
- package/@generated/persisted-documents.json +1 -1
- package/@generated/schema.graphql +4 -0
- package/CHANGELOG.md +16 -0
- package/cms/faststore/my-account/components/cms_component__accountnavigation.jsonc +10 -0
- package/cms/faststore/my-account/components/cms_component__accountorderdelivery.jsonc +80 -0
- package/cms/faststore/my-account/components/cms_component__accountorderdetails.jsonc +25 -0
- package/cms/faststore/my-account/components/cms_component__accountorderslist.jsonc +10 -0
- package/cms/faststore/my-account/components/cms_component__accountorderstatus.jsonc +90 -0
- package/cms/faststore/my-account/components/cms_component__accountordersummary.jsonc +25 -0
- package/package.json +6 -6
- package/src/components/account/Drawer/OrganizationDrawer/ContractSwitcher/ContractSwitcher.tsx +4 -6
- package/src/components/account/Drawer/OrganizationDrawer/OrganizationDrawer.tsx +18 -3
- package/src/components/account/Drawer/OrganizationDrawer/OrganizationDrawerFooter.tsx +13 -2
- package/src/components/account/Drawer/OrganizationDrawer/OrganizationDrawerHeader.tsx +1 -0
- package/src/components/account/Drawer/OrganizationDrawer/section.module.scss +7 -0
- package/src/components/account/Drawer/OrganizationSignInButton/OrganizationSignInButton.tsx +7 -9
- package/src/components/account/Menu/Menu.tsx +24 -7
- package/src/components/account/orders/ListOrders/FilterSlider/FilterFacetPendingApproval/FilterFacetPendingApproval.tsx +8 -3
- package/src/components/account/orders/ListOrders/FilterSlider/FilterSlider.tsx +1 -0
- package/src/components/account/orders/ListOrders/ListOrders.tsx +2 -2
- package/src/components/account/orders/ListOrders/listOrdersLabels.ts +4 -0
- package/src/components/account/orders/OrderDetails/BuyingPolicyAlert/BuyingPolicyAlert.tsx +45 -9
- package/src/components/account/orders/OrderDetails/DeliveryCard/DeliveryCard.tsx +14 -3
- package/src/components/account/orders/OrderDetails/DeliveryOptionAccordion/DeliveryOptionAccordion.tsx +4 -1
- package/src/components/account/orders/OrderDetails/DeliveryOptionAccordion/DeliveryOptionAccordionDeliveryInfo.tsx +14 -7
- package/src/components/account/orders/OrderDetails/DeliveryOptionAccordion/DeliveryOptionAccordionProducts.tsx +8 -5
- package/src/components/account/orders/OrderDetails/OrderDetailsHeader.tsx +5 -0
- package/src/components/account/orders/OrderDetails/StatusCard/StatusCard.tsx +26 -89
- package/src/components/account/orders/OrderDetails/StatusCard/statusCardLocalization.ts +114 -0
- package/src/components/account/orders/OrderDetails/SummaryCard/SummaryCard.tsx +2 -1
- package/src/components/account/orders/OrderDetails/SummaryCard/getOrderTotalLabel.ts +19 -0
- package/src/components/account/orders/OrderDetails/getDeliveryOptionLabel.ts +135 -0
- package/src/components/account/orders/OrderDetails/orderDetailsLabels.ts +73 -0
- package/src/components/navigation/Navbar/Navbar.tsx +17 -19
- package/src/components/navigation/Navbar/NavbarAccountArea.tsx +57 -0
- package/src/components/navigation/NavbarSlider/NavbarSlider.tsx +16 -18
- package/src/components/sections/Account/AccountOrderDelivery.tsx +3 -1
- package/src/components/sections/Account/AccountOrderDetails.tsx +16 -0
- package/src/components/sections/Account/AccountOrderStatus.tsx +27 -3
- package/src/components/sections/Account/AccountOrdersList.tsx +4 -0
- package/src/pages/api/fs/switch-contract.ts +194 -0
- package/src/pages/api/graphql.ts +108 -69
- package/src/sdk/account/accountPageContext.ts +14 -3
- package/src/sdk/account/changeContractToken.ts +19 -63
- package/src/sdk/account/clearPersistedSessionState.ts +13 -7
- package/src/sdk/account/getMyAccountRoutes.ts +2 -0
- package/src/sdk/account/useAvailableContracts.ts +1 -0
- package/src/sdk/account/useSwitchContract.ts +11 -3
- package/src/sdk/overrides/PageProvider.tsx +1 -1
- package/src/sdk/session/index.ts +2 -1
- package/src/sdk/session/isSignInAreaResolved.ts +12 -0
- package/test/components/account/ContractSwitcher.browser.test.tsx +42 -0
- package/test/components/account/Menu.browser.test.tsx +123 -0
- package/test/components/account/OrganizationDrawer.browser.test.tsx +148 -0
- package/test/components/account/OrganizationDrawerFooter.browser.test.tsx +95 -1
- package/test/components/account/OrganizationDrawerHeader.browser.test.tsx +2 -0
- package/test/components/account/OrganizationSignInButton.browser.test.tsx +49 -0
- package/test/components/account/orders/BuyingPolicyAlert.browser.test.tsx +342 -0
- package/test/components/account/orders/DeliveryCard.browser.test.tsx +36 -0
- package/test/components/account/orders/DeliveryOptionAccordion.browser.test.tsx +76 -0
- package/test/components/account/orders/DeliveryOptionAccordionDeliveryInfo.browser.test.tsx +41 -0
- package/test/components/account/orders/DeliveryOptionAccordionProducts.browser.test.tsx +35 -0
- package/test/components/account/orders/FilterFacetPendingApproval.browser.test.tsx +25 -0
- package/test/components/account/orders/StatusCard.browser.test.tsx +37 -0
- package/test/components/account/orders/StatusCard.test.ts +101 -0
- package/test/components/account/orders/SummaryCard.browser.test.tsx +59 -0
- package/test/components/account/orders/getDeliveryOptionLabel.test.ts +129 -0
- package/test/components/account/orders/getOrderTotalLabel.test.ts +35 -0
- package/test/components/account/orders/listOrdersLabels.test.ts +23 -0
- package/test/components/account/orders/orderDetailsLabels.test.ts +13 -0
- package/test/components/navigation/Navbar.browser.test.tsx +183 -0
- package/test/components/navigation/NavbarAccountArea.browser.test.tsx +115 -0
- package/test/components/navigation/NavbarSlider.browser.test.tsx +143 -0
- package/test/components/sections/Account/AccountOrderDelivery.browser.test.tsx +72 -0
- package/test/components/sections/Account/AccountOrderDetails.browser.test.tsx +151 -0
- package/test/components/sections/Account/AccountOrderStatus.browser.test.tsx +59 -0
- package/test/components/sections/Account/AccountOrdersList.browser.test.tsx +67 -0
- package/test/pages/api/graphql.test.ts +27 -1
- package/test/pages/api/switch-contract.test.ts +385 -0
- package/test/sdk/account/changeContractToken.browser.test.ts +23 -71
- package/test/sdk/account/clearPersistedSessionState.browser.test.ts +24 -0
- package/test/sdk/account/getMyAccountRoutes.test.ts +11 -1
- package/test/sdk/account/useSwitchContract.browser.test.ts +21 -0
- package/test/sdk/session/isSignInAreaResolved.test.ts +22 -0
- package/test/utils/userOrderStatus.test.ts +22 -0
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { parse } from 'cookie'
|
|
2
|
+
import type { NextApiHandler, NextApiRequest, NextApiResponse } from 'next'
|
|
3
|
+
|
|
4
|
+
import storeConfig from 'discovery.config'
|
|
5
|
+
import { expireCookieServer, getCookieDomains } from 'src/utils/clearCookies'
|
|
6
|
+
import { getRequestHostname } from 'src/utils/getRequestHostname'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Cookies holding the checkout orderForm of the previous commercial context.
|
|
10
|
+
* Both are `HttpOnly`, so they are unreachable from `document.cookie` and can
|
|
11
|
+
* only be expired from the server (B2BTEAM-3827).
|
|
12
|
+
*/
|
|
13
|
+
const ORDER_FORM_COOKIES = [
|
|
14
|
+
'checkout.vtex.com',
|
|
15
|
+
'CheckoutOrderFormOwnership',
|
|
16
|
+
] as const
|
|
17
|
+
|
|
18
|
+
const SWITCH_PROPERTIES_TIMEOUT_MS = 30_000
|
|
19
|
+
const DEFAULT_AUTH_COOKIE_MAX_AGE = 86_400
|
|
20
|
+
|
|
21
|
+
type VtexAuthCookie = {
|
|
22
|
+
Name: string
|
|
23
|
+
Value: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Untrusted upstream payload: every field is validated before use. */
|
|
27
|
+
type SwitchPropertiesResponse = {
|
|
28
|
+
authStatus?: string
|
|
29
|
+
expiresIn?: unknown
|
|
30
|
+
authCookie?: VtexAuthCookie | null
|
|
31
|
+
accountAuthCookie?: VtexAuthCookie | null
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type SwitchContractResponse = {
|
|
35
|
+
success: boolean
|
|
36
|
+
error?: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const isAuthCookie = (cookie: unknown): cookie is VtexAuthCookie => {
|
|
40
|
+
const candidate = cookie as VtexAuthCookie | null
|
|
41
|
+
|
|
42
|
+
return Boolean(candidate?.Name?.trim() && candidate?.Value)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const isSecureRequest = (request: NextApiRequest): boolean => {
|
|
46
|
+
const forwarded = request.headers['x-forwarded-proto']
|
|
47
|
+
const proto = Array.isArray(forwarded) ? forwarded[0] : forwarded
|
|
48
|
+
|
|
49
|
+
if (proto) {
|
|
50
|
+
return proto.split(',')[0].trim() === 'https'
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const hostname = getRequestHostname(request.headers.host)
|
|
54
|
+
|
|
55
|
+
return hostname !== 'localhost' && hostname !== '127.0.0.1'
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* A non-positive `expiresIn` would turn into `Max-Age=0`, which expires the
|
|
60
|
+
* credential the response is supposed to install — logging the buyer out while
|
|
61
|
+
* the route reports success. Anything that is not a positive integer falls back
|
|
62
|
+
* to the default.
|
|
63
|
+
*/
|
|
64
|
+
const resolveMaxAge = (expiresIn: unknown): number =>
|
|
65
|
+
typeof expiresIn === 'number' && Number.isInteger(expiresIn) && expiresIn > 0
|
|
66
|
+
? expiresIn
|
|
67
|
+
: DEFAULT_AUTH_COOKIE_MAX_AGE
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Mirrors the attributes the storefront used to write via `document.cookie`.
|
|
71
|
+
* `HttpOnly` is deliberately left out: the cookie lives on a domain shared
|
|
72
|
+
* with VTEX's own checkout, so hardening it is a separate, explicitly tested
|
|
73
|
+
* change.
|
|
74
|
+
*/
|
|
75
|
+
const buildAuthCookies = (
|
|
76
|
+
payload: SwitchPropertiesResponse,
|
|
77
|
+
secure: boolean
|
|
78
|
+
): string[] => {
|
|
79
|
+
const maxAge = resolveMaxAge(payload.expiresIn)
|
|
80
|
+
const secureAttr = secure ? '; Secure' : ''
|
|
81
|
+
|
|
82
|
+
return [payload.authCookie, payload.accountAuthCookie]
|
|
83
|
+
.filter(isAuthCookie)
|
|
84
|
+
.map(
|
|
85
|
+
(cookie) =>
|
|
86
|
+
`${cookie.Name}=${cookie.Value}; Path=/; Max-Age=${maxAge}; SameSite=Lax${secureAttr}`
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const buildOrderFormExpiryCookies = (request: NextApiRequest): string[] => {
|
|
91
|
+
const hostname = getRequestHostname(request.headers.host)
|
|
92
|
+
const domains = hostname ? getCookieDomains(hostname) : [undefined]
|
|
93
|
+
const cookies = parse(request.headers.cookie ?? '')
|
|
94
|
+
|
|
95
|
+
return ORDER_FORM_COOKIES.filter((name) => cookies[name]).flatMap((name) =>
|
|
96
|
+
domains.map((domain) => expireCookieServer({ name, path: '/', domain }))
|
|
97
|
+
)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Switches the buyer's active contract and drops the previous contract's
|
|
102
|
+
* checkout orderForm in a single response.
|
|
103
|
+
*
|
|
104
|
+
* Relays to VTEX Identity server-side, forwarding the buyer cookie as the only
|
|
105
|
+
* credential — no app keys involved, same as `commerce.storeFront`. On success
|
|
106
|
+
* the new auth cookie and the orderForm expiry travel in the same `Set-Cookie`
|
|
107
|
+
* batch, so the browser can never end up authenticated as the new contract
|
|
108
|
+
* while still carrying the old contract's orderForm. On any failure nothing is
|
|
109
|
+
* emitted at all and the previous contract stays active.
|
|
110
|
+
*/
|
|
111
|
+
const handler: NextApiHandler<SwitchContractResponse> = async (
|
|
112
|
+
request: NextApiRequest,
|
|
113
|
+
response: NextApiResponse<SwitchContractResponse>
|
|
114
|
+
) => {
|
|
115
|
+
if (request.method !== 'POST') {
|
|
116
|
+
response.status(405).end()
|
|
117
|
+
return
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const { contractId } = request.body ?? {}
|
|
121
|
+
|
|
122
|
+
if (!contractId || typeof contractId !== 'string') {
|
|
123
|
+
response
|
|
124
|
+
.status(400)
|
|
125
|
+
.json({ success: false, error: 'contractId is required' })
|
|
126
|
+
return
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const account = storeConfig.api.storeId
|
|
130
|
+
const an = encodeURIComponent(account)
|
|
131
|
+
const url = `https://${account}.myvtex.com/api/authenticator/storefront/credential/switch-properties?an=${an}`
|
|
132
|
+
|
|
133
|
+
try {
|
|
134
|
+
const switchResponse = await fetch(url, {
|
|
135
|
+
method: 'POST',
|
|
136
|
+
headers: {
|
|
137
|
+
'Content-Type': 'application/json',
|
|
138
|
+
cookie: request.headers.cookie ?? '',
|
|
139
|
+
},
|
|
140
|
+
body: JSON.stringify({ properties: { customerId: contractId } }),
|
|
141
|
+
signal: AbortSignal.timeout(SWITCH_PROPERTIES_TIMEOUT_MS),
|
|
142
|
+
// A redirect is never a successful switch, and following one would carry
|
|
143
|
+
// the buyer's cookie to whatever target the upstream names (CVE-2023-45143
|
|
144
|
+
// on Node < 20.8.1). Fail closed instead.
|
|
145
|
+
redirect: 'error',
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
if (!switchResponse.ok) {
|
|
149
|
+
const unauthorized =
|
|
150
|
+
switchResponse.status === 401 || switchResponse.status === 403
|
|
151
|
+
|
|
152
|
+
response
|
|
153
|
+
.status(unauthorized ? 401 : 502)
|
|
154
|
+
.json({ success: false, error: 'Failed to switch contract' })
|
|
155
|
+
return
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const payload = (await switchResponse.json()) as SwitchPropertiesResponse
|
|
159
|
+
|
|
160
|
+
if (
|
|
161
|
+
payload.authStatus?.toLowerCase() !== 'success' ||
|
|
162
|
+
!isAuthCookie(payload.authCookie)
|
|
163
|
+
) {
|
|
164
|
+
response
|
|
165
|
+
.status(502)
|
|
166
|
+
.json({ success: false, error: 'Contract switch was not successful' })
|
|
167
|
+
return
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
response.setHeader('Set-Cookie', [
|
|
171
|
+
...buildAuthCookies(payload, isSecureRequest(request)),
|
|
172
|
+
...buildOrderFormExpiryCookies(request),
|
|
173
|
+
])
|
|
174
|
+
|
|
175
|
+
response.status(200).json({ success: true })
|
|
176
|
+
} catch (error) {
|
|
177
|
+
const timedOut =
|
|
178
|
+
error instanceof Error &&
|
|
179
|
+
(error.name === 'TimeoutError' || error.name === 'AbortError')
|
|
180
|
+
|
|
181
|
+
// Only the error name is logged: the request and response carry the buyer's
|
|
182
|
+
// credentials.
|
|
183
|
+
console.error(
|
|
184
|
+
'switch-contract: upstream failure',
|
|
185
|
+
error instanceof Error ? error.name : 'unknown'
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
response
|
|
189
|
+
.status(timedOut ? 504 : 502)
|
|
190
|
+
.json({ success: false, error: 'Failed to switch contract' })
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export default handler
|
package/src/pages/api/graphql.ts
CHANGED
|
@@ -150,6 +150,112 @@ const hasVtexIdclientAutCookie = (request: NextApiRequest): boolean => {
|
|
|
150
150
|
)
|
|
151
151
|
}
|
|
152
152
|
|
|
153
|
+
/**
|
|
154
|
+
* Responds to a GraphQL execution that returned errors: recovers the
|
|
155
|
+
* upstream status from a FastStoreError when possible instead of collapsing
|
|
156
|
+
* everything to 500, and logs every error for observability.
|
|
157
|
+
*/
|
|
158
|
+
const respondWithGraphqlErrors = (
|
|
159
|
+
errors: readonly unknown[],
|
|
160
|
+
response: Parameters<NextApiHandler>[1]
|
|
161
|
+
) => {
|
|
162
|
+
// After error masking, entries are GraphQLError instances whose `name` is
|
|
163
|
+
// "GraphQLError" — the original FastStoreError (carrying the upstream
|
|
164
|
+
// status) is nested in `originalError`. Recover it so the BFF propagates
|
|
165
|
+
// the real status instead of collapsing everything to 500.
|
|
166
|
+
const fastStoreError = errors.map(recoverFastStoreError).find(Boolean)
|
|
167
|
+
const reportedErrors = errors.map((graphqlError) => {
|
|
168
|
+
const fsError = recoverFastStoreError(graphqlError)
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
message: (graphqlError as { message?: string })?.message,
|
|
172
|
+
status: fsError?.extensions.status,
|
|
173
|
+
type: fsError?.extensions.type,
|
|
174
|
+
}
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
console.error('Graphql execution returned with error: ', reportedErrors)
|
|
178
|
+
OTELLogger(
|
|
179
|
+
'error',
|
|
180
|
+
'Graphql execution returned with error: %o',
|
|
181
|
+
reportedErrors
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
const status = fastStoreError?.extensions.status ?? 500
|
|
185
|
+
|
|
186
|
+
// Error responses are never cacheable: some upstream statuses (404, 410,
|
|
187
|
+
// ...) are heuristically cacheable by intermediaries per RFC 9111 §4.2.2,
|
|
188
|
+
// which would let a CDN cache an error for this operation.
|
|
189
|
+
response.setHeader('cache-control', 'no-store')
|
|
190
|
+
|
|
191
|
+
// No recoverable FastStoreError: keep the masked, body-less 500.
|
|
192
|
+
if (!fastStoreError) {
|
|
193
|
+
response.status(status).end()
|
|
194
|
+
return
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Only FastStoreError-derived details are exposed. `type`/`status` are a
|
|
198
|
+
// closed enum (safe everywhere); the free-text `message` may echo raw
|
|
199
|
+
// upstream text, so it is restricted to non-production responses. The
|
|
200
|
+
// full message is still available server-side via the log above.
|
|
201
|
+
const responseError = {
|
|
202
|
+
extensions: {
|
|
203
|
+
type: fastStoreError.extensions.type,
|
|
204
|
+
status: fastStoreError.extensions.status,
|
|
205
|
+
},
|
|
206
|
+
...(process.env.NODE_ENV === 'production'
|
|
207
|
+
? {}
|
|
208
|
+
: { message: fastStoreError.message }),
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
response.status(status)
|
|
212
|
+
response.setHeader('content-type', 'application/json')
|
|
213
|
+
response.send(JSON.stringify({ errors: [responseError] }))
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Responds to an error thrown out of the main handler try-block (a rejected
|
|
218
|
+
* `parseRequest`/`execute`, or anything else unexpected).
|
|
219
|
+
*/
|
|
220
|
+
const respondToUnexpectedError = (
|
|
221
|
+
err: unknown,
|
|
222
|
+
response: Parameters<NextApiHandler>[1]
|
|
223
|
+
) => {
|
|
224
|
+
// Same rationale as `respondWithGraphqlErrors`: a 400/401/500 here must
|
|
225
|
+
// never be cached. This path can run after the success branch has already
|
|
226
|
+
// set a cacheable cache-control (e.g. `setHeader('set-cookie', ...)`
|
|
227
|
+
// rejecting a malformed upstream cookie, or `JSON.stringify` throwing
|
|
228
|
+
// after cache-control was set but before `send` completed). Guarded by
|
|
229
|
+
// `headersSent`: if the throw came from `send()` itself after headers were
|
|
230
|
+
// already flushed, `setHeader` would throw ERR_HTTP_HEADERS_SENT and this
|
|
231
|
+
// would fail to produce a handled response.
|
|
232
|
+
if (!response.headersSent) {
|
|
233
|
+
response.setHeader('cache-control', 'no-store')
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
console.error(
|
|
237
|
+
'Something unexpected occurred querying Graphql endpoint: \n',
|
|
238
|
+
err
|
|
239
|
+
)
|
|
240
|
+
OTELLogger(
|
|
241
|
+
'error',
|
|
242
|
+
'Something unexpected occurred querying Graphql endpoint: %o',
|
|
243
|
+
err
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
if (err instanceof BadRequestError) {
|
|
247
|
+
response.status(400).end()
|
|
248
|
+
return
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (err instanceof UnauthorizedError) {
|
|
252
|
+
response.status(401).end()
|
|
253
|
+
return
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
response.status(500).end()
|
|
257
|
+
}
|
|
258
|
+
|
|
153
259
|
const handler: NextApiHandler = async (request, response) => {
|
|
154
260
|
if (request.method !== 'POST' && request.method !== 'GET') {
|
|
155
261
|
response.status(405).end()
|
|
@@ -222,53 +328,7 @@ const handler: NextApiHandler = async (request, response) => {
|
|
|
222
328
|
const hasErrors = Array.isArray(errors) && errors.length > 0
|
|
223
329
|
|
|
224
330
|
if (hasErrors) {
|
|
225
|
-
|
|
226
|
-
// `name` is "GraphQLError" — the original FastStoreError (carrying the
|
|
227
|
-
// upstream status) is nested in `originalError`. Recover it so the BFF
|
|
228
|
-
// propagates the real status instead of collapsing everything to 500.
|
|
229
|
-
const fastStoreError = errors.map(recoverFastStoreError).find(Boolean)
|
|
230
|
-
const reportedErrors = errors.map((graphqlError) => {
|
|
231
|
-
const fsError = recoverFastStoreError(graphqlError)
|
|
232
|
-
|
|
233
|
-
return {
|
|
234
|
-
message: (graphqlError as { message?: string })?.message,
|
|
235
|
-
status: fsError?.extensions.status,
|
|
236
|
-
type: fsError?.extensions.type,
|
|
237
|
-
}
|
|
238
|
-
})
|
|
239
|
-
|
|
240
|
-
console.error('Graphql execution returned with error: ', reportedErrors)
|
|
241
|
-
OTELLogger(
|
|
242
|
-
'error',
|
|
243
|
-
'Graphql execution returned with error: %o',
|
|
244
|
-
reportedErrors
|
|
245
|
-
)
|
|
246
|
-
|
|
247
|
-
const status = fastStoreError?.extensions.status ?? 500
|
|
248
|
-
|
|
249
|
-
// No recoverable FastStoreError: keep the masked, body-less 500.
|
|
250
|
-
if (!fastStoreError) {
|
|
251
|
-
response.status(status).end()
|
|
252
|
-
return
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
// Only FastStoreError-derived details are exposed. `type`/`status` are a
|
|
256
|
-
// closed enum (safe everywhere); the free-text `message` may echo raw
|
|
257
|
-
// upstream text, so it is restricted to non-production responses. The
|
|
258
|
-
// full message is still available server-side via the log above.
|
|
259
|
-
const responseError = {
|
|
260
|
-
extensions: {
|
|
261
|
-
type: fastStoreError.extensions.type,
|
|
262
|
-
status: fastStoreError.extensions.status,
|
|
263
|
-
},
|
|
264
|
-
...(process.env.NODE_ENV !== 'production'
|
|
265
|
-
? { message: fastStoreError.message }
|
|
266
|
-
: {}),
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
response.status(status)
|
|
270
|
-
response.setHeader('content-type', 'application/json')
|
|
271
|
-
response.send(JSON.stringify({ errors: [responseError] }))
|
|
331
|
+
respondWithGraphqlErrors(errors, response)
|
|
272
332
|
return
|
|
273
333
|
}
|
|
274
334
|
|
|
@@ -321,28 +381,7 @@ const handler: NextApiHandler = async (request, response) => {
|
|
|
321
381
|
response.setHeader('content-type', 'application/json')
|
|
322
382
|
response.send(JSON.stringify({ data, errors }))
|
|
323
383
|
} catch (err) {
|
|
324
|
-
|
|
325
|
-
'Something unexpected occurred querying Graphql endpoint: \n',
|
|
326
|
-
err
|
|
327
|
-
)
|
|
328
|
-
OTELLogger(
|
|
329
|
-
'error',
|
|
330
|
-
'Something unexpected occurred querying Graphql endpoint: %o',
|
|
331
|
-
err
|
|
332
|
-
)
|
|
333
|
-
|
|
334
|
-
if (err instanceof BadRequestError) {
|
|
335
|
-
response.status(400).end()
|
|
336
|
-
return
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
if (err instanceof UnauthorizedError) {
|
|
340
|
-
response.status(401).end()
|
|
341
|
-
return
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
response.status(500).end()
|
|
345
|
-
return
|
|
384
|
+
respondToUnexpectedError(err, response)
|
|
346
385
|
}
|
|
347
386
|
}
|
|
348
387
|
|
|
@@ -4,8 +4,9 @@ import type {
|
|
|
4
4
|
ServerOrderDetailsQueryQuery,
|
|
5
5
|
ServerProfileQueryQuery,
|
|
6
6
|
} from '@generated/graphql'
|
|
7
|
+
import { useContext } from 'react'
|
|
7
8
|
import type { PageGlobalContext } from 'src/sdk/overrides/PageProvider'
|
|
8
|
-
import { usePage } from 'src/sdk/overrides/PageProvider'
|
|
9
|
+
import { PageContext, usePage } from 'src/sdk/overrides/PageProvider'
|
|
9
10
|
import type { OrderStatusCmsLabels } from 'src/utils/userOrderStatus'
|
|
10
11
|
import type { AccountNavigationLabels } from './getMyAccountRoutes'
|
|
11
12
|
|
|
@@ -76,9 +77,19 @@ export interface AccountPageContext extends PageGlobalContext {
|
|
|
76
77
|
navigationLabels?: AccountNavigationLabels
|
|
77
78
|
}
|
|
78
79
|
|
|
80
|
+
/**
|
|
81
|
+
* Same data as `usePage<AccountPageContext>().navigationLabels`, but safe to
|
|
82
|
+
* call from components (e.g. the B2B organization drawer) that render both
|
|
83
|
+
* inside and outside a My Account `PageProvider` tree — returns `undefined`
|
|
84
|
+
* instead of throwing when there is no provider, so callers fall back to
|
|
85
|
+
* their hardcoded default label.
|
|
86
|
+
*/
|
|
79
87
|
export function useAccountNavigationLabels() {
|
|
80
|
-
const
|
|
81
|
-
|
|
88
|
+
const context = useContext(PageContext)?.context
|
|
89
|
+
|
|
90
|
+
return context && 'navigationLabels' in context
|
|
91
|
+
? context.navigationLabels
|
|
92
|
+
: undefined
|
|
82
93
|
}
|
|
83
94
|
|
|
84
95
|
export function useAccountPageData<
|
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
import fetch from 'isomorphic-unfetch'
|
|
2
2
|
|
|
3
|
-
import storeConfig from '../../../discovery.config'
|
|
4
|
-
|
|
5
3
|
export class ContractSwitchError extends Error {
|
|
6
4
|
constructor(message: string) {
|
|
7
5
|
super(message)
|
|
@@ -11,51 +9,20 @@ export class ContractSwitchError extends Error {
|
|
|
11
9
|
|
|
12
10
|
export const isContractSwitchEnabled = true
|
|
13
11
|
|
|
14
|
-
|
|
15
|
-
Name: string
|
|
16
|
-
Value: string
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export type SwitchPropertiesResponse = {
|
|
20
|
-
authStatus?: string
|
|
21
|
-
expiresIn?: number
|
|
22
|
-
authCookie?: VtexAuthCookie | null
|
|
23
|
-
accountAuthCookie?: VtexAuthCookie | null
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
const DEFAULT_AUTH_COOKIE_MAX_AGE = 86_400
|
|
27
|
-
const SWITCH_PROPERTIES_TIMEOUT_MS = 30_000
|
|
28
|
-
|
|
29
|
-
export function applyVtexAuthCookieFromSwitchResponse(
|
|
30
|
-
response: SwitchPropertiesResponse
|
|
31
|
-
): void {
|
|
32
|
-
if (globalThis.window === undefined) {
|
|
33
|
-
return
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
const maxAge = response.expiresIn ?? DEFAULT_AUTH_COOKIE_MAX_AGE
|
|
37
|
-
const secure =
|
|
38
|
-
globalThis.window.location.protocol === 'https:' ? '; secure' : ''
|
|
39
|
-
|
|
40
|
-
const cookies = [response.authCookie, response.accountAuthCookie].filter(
|
|
41
|
-
(cookie): cookie is VtexAuthCookie =>
|
|
42
|
-
Boolean(cookie?.Name?.trim() && cookie?.Value)
|
|
43
|
-
)
|
|
44
|
-
|
|
45
|
-
for (const cookie of cookies) {
|
|
46
|
-
document.cookie = `${cookie.Name}=${cookie.Value}; path=/; max-age=${maxAge}; samesite=lax${secure}`
|
|
47
|
-
}
|
|
48
|
-
}
|
|
12
|
+
const SWITCH_CONTRACT_TIMEOUT_MS = 30_000
|
|
49
13
|
|
|
50
14
|
/**
|
|
51
|
-
* Switches the buyer's active contract
|
|
15
|
+
* Switches the buyer's active contract through `/api/fs/switch-contract`.
|
|
52
16
|
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
17
|
+
* The route relays the call to VTEX Identity and answers with a single
|
|
18
|
+
* `Set-Cookie` batch: the new auth cookie plus the expiry of the previous
|
|
19
|
+
* contract's checkout orderForm cookies, which are `HttpOnly` and therefore
|
|
20
|
+
* unreachable from here. Because both travel in the same response, there is no
|
|
21
|
+
* window where the browser holds the new credential alongside the old
|
|
22
|
+
* orderForm (B2BTEAM-3827).
|
|
55
23
|
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
* on hard failures so `switchContract` can keep the previous contract active.
|
|
24
|
+
* Throws `ContractSwitchError` on any failure so `switchContract` can keep the
|
|
25
|
+
* previous contract active.
|
|
59
26
|
*/
|
|
60
27
|
export async function changeContractToken(
|
|
61
28
|
contractId: string
|
|
@@ -64,23 +31,16 @@ export async function changeContractToken(
|
|
|
64
31
|
throw new ContractSwitchError('Missing contractId for contract switch')
|
|
65
32
|
}
|
|
66
33
|
|
|
67
|
-
const an = encodeURIComponent(storeConfig.api.storeId)
|
|
68
|
-
const url = `/api/authenticator/storefront/credential/switch-properties?an=${an}`
|
|
69
|
-
|
|
70
34
|
let response: Response
|
|
71
35
|
try {
|
|
72
|
-
response = await fetch(
|
|
36
|
+
response = await fetch('/api/fs/switch-contract', {
|
|
73
37
|
method: 'POST',
|
|
74
38
|
credentials: 'include',
|
|
75
39
|
headers: {
|
|
76
40
|
'Content-Type': 'application/json',
|
|
77
41
|
},
|
|
78
|
-
body: JSON.stringify({
|
|
79
|
-
|
|
80
|
-
customerId: contractId,
|
|
81
|
-
},
|
|
82
|
-
}),
|
|
83
|
-
signal: AbortSignal.timeout(SWITCH_PROPERTIES_TIMEOUT_MS),
|
|
42
|
+
body: JSON.stringify({ contractId }),
|
|
43
|
+
signal: AbortSignal.timeout(SWITCH_CONTRACT_TIMEOUT_MS),
|
|
84
44
|
})
|
|
85
45
|
} catch (error) {
|
|
86
46
|
if (
|
|
@@ -101,19 +61,15 @@ export async function changeContractToken(
|
|
|
101
61
|
)
|
|
102
62
|
}
|
|
103
63
|
|
|
104
|
-
|
|
64
|
+
// Reading the body to completion guarantees the `Set-Cookie` headers are
|
|
65
|
+
// committed before `switchContract` reloads the page.
|
|
66
|
+
const payload = (await response.json().catch(() => null)) as {
|
|
67
|
+
success?: boolean
|
|
68
|
+
} | null
|
|
105
69
|
|
|
106
|
-
if (payload
|
|
70
|
+
if (!payload?.success) {
|
|
107
71
|
throw new ContractSwitchError('Contract switch was not successful')
|
|
108
72
|
}
|
|
109
73
|
|
|
110
|
-
if (!payload.authCookie?.Name || !payload.authCookie?.Value) {
|
|
111
|
-
throw new ContractSwitchError(
|
|
112
|
-
'Contract switch succeeded but no auth cookie was returned'
|
|
113
|
-
)
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
applyVtexAuthCookieFromSwitchResponse(payload)
|
|
117
|
-
|
|
118
74
|
return true
|
|
119
75
|
}
|
|
@@ -4,20 +4,26 @@ import {
|
|
|
4
4
|
STORAGE_KEY_PERSON_ID,
|
|
5
5
|
} from 'src/utils/cookieCacheBusting'
|
|
6
6
|
|
|
7
|
+
/** Persisted `@faststore/sdk` stores that belong to the previous commercial context. */
|
|
8
|
+
export const PERSISTED_STORE_KEYS = ['fs::session', 'fs::cart'] as const
|
|
9
|
+
|
|
7
10
|
/**
|
|
8
|
-
* Clears client-side
|
|
9
|
-
*
|
|
11
|
+
* Clears client-side persistence of the previous commercial context so a hard
|
|
12
|
+
* reload rehydrates from validateSession / validateCart under the new
|
|
13
|
+
* contract.
|
|
14
|
+
*
|
|
15
|
+
* The checkout orderForm cookies are `HttpOnly` and are expired by
|
|
16
|
+
* `/api/fs/switch-contract` instead (B2BTEAM-3827).
|
|
10
17
|
*/
|
|
11
18
|
export async function clearPersistedSessionState(): Promise<void> {
|
|
12
19
|
try {
|
|
13
20
|
const { del } = await import('idb-keyval')
|
|
14
|
-
await
|
|
21
|
+
await Promise.all(
|
|
22
|
+
PERSISTED_STORE_KEYS.map((key) => del(key).catch(() => {}))
|
|
23
|
+
)
|
|
15
24
|
} catch {}
|
|
16
25
|
|
|
17
|
-
if (typeof sessionStorage === 'undefined')
|
|
18
|
-
return
|
|
19
|
-
}
|
|
20
|
-
|
|
26
|
+
if (typeof sessionStorage === 'undefined') return
|
|
21
27
|
try {
|
|
22
28
|
sessionStorage.removeItem(SESSION_READY_KEY)
|
|
23
29
|
sessionStorage.removeItem(STORAGE_KEY_PERSON_ID)
|
|
@@ -22,6 +22,7 @@ export type AccountNavigationLabels = Partial<{
|
|
|
22
22
|
companyLabel: string
|
|
23
23
|
contractLabel: string
|
|
24
24
|
cardsLabel: string
|
|
25
|
+
quotesLabel: string
|
|
25
26
|
}>
|
|
26
27
|
|
|
27
28
|
interface GetMyAccountRouteParams {
|
|
@@ -45,6 +46,7 @@ const ROUTE_LABEL_KEYS: Record<string, keyof AccountNavigationLabels> = {
|
|
|
45
46
|
[USER_DETAILS_ROUTE]: 'userDetailsLabel',
|
|
46
47
|
[SECURITY_ROUTE]: 'securityLabel',
|
|
47
48
|
[CARDS_ROUTE]: 'cardsLabel',
|
|
49
|
+
[QUOTES_ROUTE]: 'quotesLabel',
|
|
48
50
|
}
|
|
49
51
|
|
|
50
52
|
// This is the default route list for My Account, we should add then as the feature is implemented
|
|
@@ -11,8 +11,11 @@ import {
|
|
|
11
11
|
* Orchestrates a full change of commercial context (REQ-06).
|
|
12
12
|
*
|
|
13
13
|
* Steps:
|
|
14
|
-
* 1. POST switch-
|
|
15
|
-
*
|
|
14
|
+
* 1. POST `/api/fs/switch-contract`, which swaps the auth cookie and expires
|
|
15
|
+
* the previous contract's checkout orderForm in one response. A failure
|
|
16
|
+
* here changes nothing, so the previous contract stays active.
|
|
17
|
+
* 2. Clear persisted session and cart state and reload the page so
|
|
18
|
+
* validateSession/validateCart run under the new contract.
|
|
16
19
|
*/
|
|
17
20
|
export const useSwitchContract = () => {
|
|
18
21
|
const [loading, setLoading] = useState(false)
|
|
@@ -31,7 +34,12 @@ export const useSwitchContract = () => {
|
|
|
31
34
|
return false
|
|
32
35
|
}
|
|
33
36
|
|
|
34
|
-
|
|
37
|
+
// The cookies were already swapped, so the reload has to happen even if
|
|
38
|
+
// clearing the local stores fails: from here on the server session is
|
|
39
|
+
// the authoritative source and validateCart rebuilds the cart.
|
|
40
|
+
try {
|
|
41
|
+
await clearPersistedSessionState()
|
|
42
|
+
} catch {}
|
|
35
43
|
|
|
36
44
|
if (globalThis.window !== undefined) {
|
|
37
45
|
globalThis.window.location.reload()
|
|
@@ -62,7 +62,7 @@ export const isSearchPage = (x: any): x is SearchPageContext =>
|
|
|
62
62
|
x?.data?.title != undefined ||
|
|
63
63
|
x?.data?.searchTerm != undefined
|
|
64
64
|
|
|
65
|
-
const PageContext = createContext<PageProviderContextValue | null>(null)
|
|
65
|
+
export const PageContext = createContext<PageProviderContextValue | null>(null)
|
|
66
66
|
|
|
67
67
|
function PageProvider({
|
|
68
68
|
context,
|
package/src/sdk/session/index.ts
CHANGED
|
@@ -282,8 +282,9 @@ export const useSession = ({ filter }: SessionOptions = { filter: true }) => {
|
|
|
282
282
|
channel,
|
|
283
283
|
isValidating,
|
|
284
284
|
isSessionReady,
|
|
285
|
+
hasValidated,
|
|
285
286
|
}),
|
|
286
|
-
[isValidating, session, channel, isSessionReady]
|
|
287
|
+
[isValidating, session, channel, isSessionReady, hasValidated]
|
|
287
288
|
)
|
|
288
289
|
}
|
|
289
290
|
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Navbar account area must not render "Sign in" for a B2B buyer whose
|
|
3
|
+
* session has not been validated yet on this page load (Design QA 26/Aug
|
|
4
|
+
* 00:02:55–00:03:52). `isSessionReady` alone is pre-seeded from sessionStorage.
|
|
5
|
+
*/
|
|
6
|
+
export const isSignInAreaResolved = ({
|
|
7
|
+
isSessionReady,
|
|
8
|
+
hasValidated,
|
|
9
|
+
}: {
|
|
10
|
+
isSessionReady: boolean
|
|
11
|
+
hasValidated: boolean
|
|
12
|
+
}): boolean => isSessionReady && hasValidated
|