@faststore/core 4.10.1 → 4.10.2-dev.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/.turbo/turbo-generate.log +3 -3
- package/.turbo/turbo-test.log +304 -252
- package/@generated/graphql.ts +1 -0
- package/@generated/schema.graphql +1 -1
- package/CHANGELOG.md +82 -0
- package/package.json +6 -6
- package/src/components/account/Drawer/OrganizationDrawer/OrganizationDrawer.tsx +3 -3
- package/src/components/account/Drawer/OrganizationDrawer/OrganizationDrawerBody.tsx +5 -7
- package/src/components/account/Drawer/OrganizationSignInButton/OrganizationSignInButton.tsx +4 -1
- package/src/components/account/Layout/Layout.tsx +5 -7
- package/src/components/account/Menu/Menu.tsx +1 -1
- package/src/components/navigation/Navbar/Navbar.tsx +3 -3
- package/src/components/navigation/Navbar/NavbarAccountArea.tsx +5 -5
- package/src/components/navigation/NavbarSlider/NavbarSlider.tsx +3 -3
- package/src/experimental/myAccountCmsServerSideProps.ts +3 -3
- package/src/experimental/myAccountServerSideProps.ts +4 -4
- package/src/pages/[slug]/p.tsx +18 -4
- package/src/pages/api/fs/logout.ts +18 -10
- package/src/pages/api/fs/switch-contract.ts +170 -21
- package/src/pages/api/graphql.ts +101 -60
- package/src/pages/pvt/account/403.tsx +5 -4
- package/src/pages/pvt/account/cards/index.tsx +5 -5
- package/src/pages/pvt/account/orders/[id].tsx +5 -5
- package/src/pages/pvt/account/orders/index.tsx +5 -5
- package/src/pages/pvt/account/profile.tsx +5 -5
- package/src/pages/pvt/account/quotes/index.tsx +5 -5
- package/src/pages/pvt/account/security.tsx +5 -5
- package/src/pages/pvt/account/user-details.tsx +6 -6
- package/src/sdk/account/getB2BSessionClaims.ts +1 -1
- package/src/sdk/account/getIsB2BBuyer.ts +28 -0
- package/src/sdk/account/refreshToken.ts +89 -11
- package/src/sdk/account/refreshTokenPaths.ts +25 -0
- package/src/sdk/account/useRefreshToken.ts +15 -11
- package/src/sdk/product/isProductNotFound.ts +13 -0
- package/src/sdk/product/usePageProductsQuery.ts +80 -21
- package/src/sdk/product/useProductNotFoundRedirect.ts +43 -0
- package/src/sdk/product/useProductQuery.ts +29 -0
- package/src/sdk/session/index.ts +46 -34
- package/src/server/cms/index.ts +59 -7
- package/src/server/cms/pdp.ts +10 -5
- package/src/server/cms/plp.ts +10 -5
- package/src/server/content/promiseCache.ts +68 -0
- package/src/server/content/service.ts +71 -51
- package/src/utils/getCheckoutParentDomain.ts +53 -0
- package/src/utils/isLocalHost.ts +2 -16
- package/src/utils/shouldBypassRefreshToken.ts +63 -0
- package/src/utils/trustedForwardedHost.ts +18 -0
- package/test/components/account/Layout.test.tsx +1 -1
- package/test/components/account/Menu.browser.test.tsx +7 -5
- package/test/components/account/OrganizationDrawer.browser.test.tsx +13 -13
- package/test/components/account/OrganizationDrawerBody.test.tsx +3 -3
- package/test/components/account/OrganizationSignInButton.browser.test.tsx +19 -4
- package/test/components/navigation/Navbar.browser.test.tsx +3 -3
- package/test/components/navigation/NavbarAccountArea.browser.test.tsx +5 -5
- package/test/components/navigation/NavbarSlider.browser.test.tsx +3 -3
- package/test/experimental/myAccountCmsServerSideProps.test.ts +5 -5
- package/test/pages/api/graphql.test.ts +175 -0
- package/test/pages/api/logout.test.ts +106 -0
- package/test/pages/api/switch-contract.test.ts +443 -15
- package/test/pages/pvt/account/403.test.ts +67 -0
- package/test/pages/pvt/account/cards/index.test.ts +3 -3
- package/test/sdk/account/getIsB2BBuyer.test.ts +52 -0
- package/test/sdk/account/refreshToken.browser.test.ts +10 -1
- package/test/sdk/account/refreshToken.test.ts +150 -18
- package/test/sdk/account/useRefreshToken.browser.test.ts +72 -0
- package/test/sdk/product/usePageProductsQuery.browser.test.ts +260 -0
- package/test/sdk/product/usePageProductsQuery.deliveryPromise.browser.test.ts +130 -0
- package/test/sdk/product/useProductNotFoundRedirect.browser.test.ts +83 -0
- package/test/sdk/product/useProductQuery.browser.test.ts +154 -0
- package/test/sdk/session/refreshTokenRoute.browser.test.ts +106 -0
- package/test/sdk/session/validateSession.browser.test.ts +111 -5
- package/test/server/cms/index.test.ts +102 -9
- package/test/server/content/promiseCache.test.ts +150 -0
- package/test/server/content/service.test.ts +277 -11
- package/test/utils/shouldBypassRefreshToken.browser.test.ts +38 -0
- package/test/utils/shouldBypassRefreshToken.test.ts +98 -0
- package/test/utils/trustedForwardedHost.test.ts +21 -0
- package/src/sdk/account/getIsRepresentative.ts +0 -20
- package/test/utils/isLocalHost.browser.test.ts +0 -37
|
@@ -27,7 +27,7 @@ import BeforeSection from 'src/customizations/src/myAccount/extensions/quotes/be
|
|
|
27
27
|
import type { MyAccountProps } from 'src/experimental/myAccountServerSideProps'
|
|
28
28
|
import type { AccountQuotesPageData } from 'src/sdk/account/accountPageContext'
|
|
29
29
|
import type { AccountNavigationLabels } from 'src/sdk/account/getMyAccountRoutes'
|
|
30
|
-
import {
|
|
30
|
+
import { getIsB2BBuyer } from 'src/sdk/account/getIsB2BBuyer'
|
|
31
31
|
import PageProvider from 'src/sdk/overrides/PageProvider'
|
|
32
32
|
import { execute } from 'src/server'
|
|
33
33
|
import { injectGlobalSections } from 'src/server/cms/global'
|
|
@@ -56,7 +56,7 @@ export default function ListQuotesPage({
|
|
|
56
56
|
navigationLabels,
|
|
57
57
|
accountPageData,
|
|
58
58
|
accountName,
|
|
59
|
-
|
|
59
|
+
isB2BBuyer,
|
|
60
60
|
}: ListQuotesPageProps) {
|
|
61
61
|
const { sections: globalSections, settings: globalSettings } =
|
|
62
62
|
globalSectionsProp ?? {}
|
|
@@ -73,7 +73,7 @@ export default function ListQuotesPage({
|
|
|
73
73
|
<NextSeo noindex nofollow />
|
|
74
74
|
|
|
75
75
|
<Layout
|
|
76
|
-
|
|
76
|
+
isB2BBuyer={isB2BBuyer}
|
|
77
77
|
accountName={accountName}
|
|
78
78
|
navigationLabels={navigationLabels}
|
|
79
79
|
>
|
|
@@ -122,7 +122,7 @@ const getServerSidePropsBase: GetServerSideProps<
|
|
|
122
122
|
Record<string, string>,
|
|
123
123
|
Locator
|
|
124
124
|
> = async (context) => {
|
|
125
|
-
const
|
|
125
|
+
const isB2BBuyer = getIsB2BBuyer({
|
|
126
126
|
headers: context.req.headers as Record<string, string>,
|
|
127
127
|
account: storeConfig.api.storeId,
|
|
128
128
|
})
|
|
@@ -273,7 +273,7 @@ const getServerSidePropsBase: GetServerSideProps<
|
|
|
273
273
|
},
|
|
274
274
|
},
|
|
275
275
|
pageSections,
|
|
276
|
-
|
|
276
|
+
isB2BBuyer,
|
|
277
277
|
},
|
|
278
278
|
}
|
|
279
279
|
}
|
|
@@ -22,7 +22,7 @@ import { default as BeforeSection } from 'src/customizations/src/myAccount/exten
|
|
|
22
22
|
import type { MyAccountProps } from 'src/experimental/myAccountServerSideProps'
|
|
23
23
|
import type { AccountSecurityPageData } from 'src/sdk/account/accountPageContext'
|
|
24
24
|
import type { AccountNavigationLabels } from 'src/sdk/account/getMyAccountRoutes'
|
|
25
|
-
import {
|
|
25
|
+
import { getIsB2BBuyer } from 'src/sdk/account/getIsB2BBuyer'
|
|
26
26
|
import { execute } from 'src/server'
|
|
27
27
|
import { injectGlobalSections } from 'src/server/cms/global'
|
|
28
28
|
import { extractAccountNavigationData } from 'src/server/cms/myAccountDefaultSections'
|
|
@@ -51,7 +51,7 @@ export default function Page({
|
|
|
51
51
|
navigationLabels,
|
|
52
52
|
accountPageData,
|
|
53
53
|
accountName,
|
|
54
|
-
|
|
54
|
+
isB2BBuyer,
|
|
55
55
|
}: SecurityPageProps) {
|
|
56
56
|
const { sections: globalSections, settings: globalSettings } =
|
|
57
57
|
globalSectionsProp ?? {}
|
|
@@ -68,7 +68,7 @@ export default function Page({
|
|
|
68
68
|
<NextSeo noindex nofollow />
|
|
69
69
|
|
|
70
70
|
<Layout
|
|
71
|
-
|
|
71
|
+
isB2BBuyer={isB2BBuyer}
|
|
72
72
|
accountName={accountName}
|
|
73
73
|
navigationLabels={navigationLabels}
|
|
74
74
|
>
|
|
@@ -101,7 +101,7 @@ const getServerSidePropsBase: GetServerSideProps<
|
|
|
101
101
|
Record<string, string>,
|
|
102
102
|
Locator
|
|
103
103
|
> = async (context) => {
|
|
104
|
-
const
|
|
104
|
+
const isB2BBuyer = getIsB2BBuyer({
|
|
105
105
|
headers: context.req.headers as Record<string, string>,
|
|
106
106
|
account: storeConfig.api.storeId,
|
|
107
107
|
})
|
|
@@ -185,7 +185,7 @@ const getServerSidePropsBase: GetServerSideProps<
|
|
|
185
185
|
},
|
|
186
186
|
globalSections: globalSectionsResult,
|
|
187
187
|
pageSections,
|
|
188
|
-
|
|
188
|
+
isB2BBuyer,
|
|
189
189
|
},
|
|
190
190
|
}
|
|
191
191
|
}
|
|
@@ -23,7 +23,7 @@ import { default as BeforeSection } from 'src/customizations/src/myAccount/exten
|
|
|
23
23
|
import type { MyAccountProps } from 'src/experimental/myAccountServerSideProps'
|
|
24
24
|
import type { AccountUserDetailsPageData } from 'src/sdk/account/accountPageContext'
|
|
25
25
|
import type { AccountNavigationLabels } from 'src/sdk/account/getMyAccountRoutes'
|
|
26
|
-
import {
|
|
26
|
+
import { getIsB2BBuyer } from 'src/sdk/account/getIsB2BBuyer'
|
|
27
27
|
import PageProvider from 'src/sdk/overrides/PageProvider'
|
|
28
28
|
import { execute } from 'src/server'
|
|
29
29
|
import { injectGlobalSections } from 'src/server/cms/global'
|
|
@@ -50,7 +50,7 @@ export default function Page({
|
|
|
50
50
|
navigationLabels,
|
|
51
51
|
accountPageData,
|
|
52
52
|
accountName,
|
|
53
|
-
|
|
53
|
+
isB2BBuyer,
|
|
54
54
|
}: UserDetailsPageProps) {
|
|
55
55
|
const { sections: globalSections, settings: globalSettings } =
|
|
56
56
|
globalSectionsProp ?? {}
|
|
@@ -67,7 +67,7 @@ export default function Page({
|
|
|
67
67
|
<NextSeo noindex nofollow />
|
|
68
68
|
|
|
69
69
|
<Layout
|
|
70
|
-
|
|
70
|
+
isB2BBuyer={isB2BBuyer}
|
|
71
71
|
accountName={accountName}
|
|
72
72
|
navigationLabels={navigationLabels}
|
|
73
73
|
>
|
|
@@ -105,7 +105,7 @@ const getServerSidePropsBase: GetServerSideProps<
|
|
|
105
105
|
Record<string, string>,
|
|
106
106
|
Locator
|
|
107
107
|
> = async (context) => {
|
|
108
|
-
const
|
|
108
|
+
const isB2BBuyer = getIsB2BBuyer({
|
|
109
109
|
headers: context.req.headers as Record<string, string>,
|
|
110
110
|
account: storeConfig.api.storeId,
|
|
111
111
|
})
|
|
@@ -118,7 +118,7 @@ const getServerSidePropsBase: GetServerSideProps<
|
|
|
118
118
|
return { redirect }
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
-
if (!
|
|
121
|
+
if (!isB2BBuyer) {
|
|
122
122
|
return {
|
|
123
123
|
redirect: {
|
|
124
124
|
destination: localizeRedirectDestination('/pvt/account', context),
|
|
@@ -209,7 +209,7 @@ const getServerSidePropsBase: GetServerSideProps<
|
|
|
209
209
|
},
|
|
210
210
|
},
|
|
211
211
|
pageSections,
|
|
212
|
-
|
|
212
|
+
isB2BBuyer,
|
|
213
213
|
},
|
|
214
214
|
}
|
|
215
215
|
}
|
|
@@ -10,7 +10,7 @@ export type B2BSessionClaims = {
|
|
|
10
10
|
/**
|
|
11
11
|
* Whether the buyer is associated with a Unit/Contract. Per the Personal
|
|
12
12
|
* Cards PRD (REQ-2), this is detected by the presence of a `unitId` claim in
|
|
13
|
-
* the VTEX ID JWT — the same cookie `
|
|
13
|
+
* the VTEX ID JWT — the same cookie `getIsB2BBuyer` already decodes.
|
|
14
14
|
* Gates the Shared tab (FR-5).
|
|
15
15
|
*
|
|
16
16
|
* Note this is the only part of the gate that lives in the token. The
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { parse } from 'cookie'
|
|
2
|
+
import { parseJwt } from 'src/utils/getCookie'
|
|
3
|
+
|
|
4
|
+
type GetIsB2BBuyerParams = {
|
|
5
|
+
headers?: Record<string, string>
|
|
6
|
+
account: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Whether the buyer is a B2B member: the VTEX ID token carries both an
|
|
11
|
+
* organizational unit and a contract, the rule Buyer Portal uses. The
|
|
12
|
+
* `isRepresentative` claim is not read, since it can be true outside B2B.
|
|
13
|
+
*/
|
|
14
|
+
export function getIsB2BBuyer({
|
|
15
|
+
headers,
|
|
16
|
+
account,
|
|
17
|
+
}: GetIsB2BBuyerParams): boolean {
|
|
18
|
+
const authCookie = parse(headers?.cookie ?? '')?.[
|
|
19
|
+
'VtexIdclientAutCookie_' + account
|
|
20
|
+
]
|
|
21
|
+
const jwt = parseJwt(authCookie)
|
|
22
|
+
|
|
23
|
+
return isNonBlankString(jwt?.unitId) && isNonBlankString(jwt?.customerId)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function isNonBlankString(value: unknown): boolean {
|
|
27
|
+
return typeof value === 'string' && value.trim() !== ''
|
|
28
|
+
}
|
|
@@ -1,10 +1,21 @@
|
|
|
1
1
|
import fetch from 'isomorphic-unfetch'
|
|
2
2
|
import { getStoreURL } from 'src/sdk/localization/useLocalizationConfig'
|
|
3
3
|
import { sanitizeHost } from 'src/utils/utilities'
|
|
4
|
+
import {
|
|
5
|
+
AUTHENTICATOR_REFRESH_TOKEN_PATH,
|
|
6
|
+
LEGACY_REFRESH_TOKEN_PATH,
|
|
7
|
+
} from './refreshTokenPaths'
|
|
4
8
|
|
|
9
|
+
/**
|
|
10
|
+
* Covers both refresh routes:
|
|
11
|
+
* - Authenticator: `{ authStatus: 'Success', refreshAfter: 1792717383 }`
|
|
12
|
+
* (epoch seconds).
|
|
13
|
+
* - Legacy VTEX ID: `{ status: 'Success', refreshAfter: '2026-03-26T02:53:39+00:00' }`.
|
|
14
|
+
*/
|
|
5
15
|
export interface RefreshTokenResponse {
|
|
6
16
|
status?: string
|
|
7
|
-
|
|
17
|
+
authStatus?: string
|
|
18
|
+
refreshAfter?: string | number
|
|
8
19
|
}
|
|
9
20
|
|
|
10
21
|
async function fetchWithRetry(
|
|
@@ -25,12 +36,12 @@ async function fetchWithRetry(
|
|
|
25
36
|
return undefined
|
|
26
37
|
}
|
|
27
38
|
|
|
28
|
-
function getRefreshTokenUrl(): string {
|
|
39
|
+
function getRefreshTokenUrl(path: string): string {
|
|
29
40
|
if (globalThis.window !== undefined) {
|
|
30
|
-
return
|
|
41
|
+
return path
|
|
31
42
|
}
|
|
32
43
|
|
|
33
|
-
return `${new URL(getStoreURL()).origin}
|
|
44
|
+
return `${new URL(getStoreURL()).origin}${path}`
|
|
34
45
|
}
|
|
35
46
|
|
|
36
47
|
function getRefreshTokenHeaders(): HeadersInit {
|
|
@@ -45,10 +56,43 @@ function getRefreshTokenHeaders(): HeadersInit {
|
|
|
45
56
|
return headers
|
|
46
57
|
}
|
|
47
58
|
|
|
48
|
-
export const
|
|
49
|
-
RefreshTokenResponse | undefined
|
|
50
|
-
|
|
51
|
-
return
|
|
59
|
+
export const isRefreshTokenSuccessful = (
|
|
60
|
+
result: RefreshTokenResponse | undefined
|
|
61
|
+
): boolean => {
|
|
62
|
+
return (result?.authStatus ?? result?.status)?.toLowerCase?.() === 'success'
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Whether the session belongs to a B2B buyer: one with an organizational unit
|
|
67
|
+
* (`unitId`) and a contract (`customerId`), the same rule the API uses to
|
|
68
|
+
* fill `session.b2b`.
|
|
69
|
+
*/
|
|
70
|
+
export const isB2BSession = (
|
|
71
|
+
session:
|
|
72
|
+
| {
|
|
73
|
+
b2b?: {
|
|
74
|
+
unitId?: string | null
|
|
75
|
+
customerId?: string | null
|
|
76
|
+
} | null
|
|
77
|
+
}
|
|
78
|
+
| null
|
|
79
|
+
| undefined
|
|
80
|
+
): boolean => Boolean(session?.b2b?.unitId && session?.b2b?.customerId)
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Refresh route for the session. B2B sessions use the Authenticator route,
|
|
84
|
+
* which keeps the selected contract (`customerId`, `unitId`,
|
|
85
|
+
* `isRepresentative`). Every other session keeps the legacy VTEX ID route, as
|
|
86
|
+
* VTEX ID recommends. There is no fallback between them: like before, a failed
|
|
87
|
+
* refresh logs the shopper out.
|
|
88
|
+
*/
|
|
89
|
+
export const getRefreshTokenPath = (isB2B: boolean): string =>
|
|
90
|
+
isB2B ? AUTHENTICATOR_REFRESH_TOKEN_PATH : LEGACY_REFRESH_TOKEN_PATH
|
|
91
|
+
|
|
92
|
+
export const refreshTokenRequest = async ({
|
|
93
|
+
isB2B = false,
|
|
94
|
+
}: { isB2B?: boolean } = {}): Promise<RefreshTokenResponse | undefined> => {
|
|
95
|
+
return fetchWithRetry(getRefreshTokenUrl(getRefreshTokenPath(isB2B)), {
|
|
52
96
|
credentials: 'include',
|
|
53
97
|
headers: getRefreshTokenHeaders(),
|
|
54
98
|
body: JSON.stringify({}),
|
|
@@ -56,8 +100,42 @@ export const refreshTokenRequest = async (): Promise<
|
|
|
56
100
|
})
|
|
57
101
|
}
|
|
58
102
|
|
|
59
|
-
|
|
103
|
+
/**
|
|
104
|
+
* Normalizes `refreshAfter` to epoch seconds as a string, the format stored in
|
|
105
|
+
* the session. Accepts epoch seconds (number or numeric string) and ISO dates.
|
|
106
|
+
* Returns `null` when the value is missing or unparseable.
|
|
107
|
+
*/
|
|
108
|
+
export const getRefreshAfterSeconds = (
|
|
60
109
|
result: RefreshTokenResponse | undefined
|
|
61
|
-
):
|
|
62
|
-
|
|
110
|
+
): string | null => {
|
|
111
|
+
const value = result?.refreshAfter
|
|
112
|
+
|
|
113
|
+
if (value === undefined || value === null || value === '') {
|
|
114
|
+
return null
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const seconds =
|
|
118
|
+
typeof value === 'number' || /^\d+$/.test(value)
|
|
119
|
+
? Number(value)
|
|
120
|
+
: new Date(value).getTime() / 1000
|
|
121
|
+
|
|
122
|
+
return Number.isFinite(seconds) ? String(Math.floor(seconds)) : null
|
|
63
123
|
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* How long to wait before refreshing again when a successful refresh carries
|
|
127
|
+
* no usable `refreshAfter`. Keeping the old, already-expired value would make
|
|
128
|
+
* every following session validation call the refresh route again.
|
|
129
|
+
*/
|
|
130
|
+
export const REFRESH_AFTER_FALLBACK_SECONDS = 60 * 60
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* `refreshAfter` to store after a successful refresh: the value from the
|
|
134
|
+
* response, or a short fallback from now when the response has none.
|
|
135
|
+
*/
|
|
136
|
+
export const getNextRefreshAfter = (
|
|
137
|
+
result: RefreshTokenResponse | undefined,
|
|
138
|
+
nowSeconds = Math.floor(Date.now() / 1000)
|
|
139
|
+
): string =>
|
|
140
|
+
getRefreshAfterSeconds(result) ??
|
|
141
|
+
String(nowSeconds + REFRESH_AFTER_FALLBACK_SECONDS)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VTEX ID refresh route. The recommended route for every non-B2B session.
|
|
3
|
+
* For B2B it drops the contract claims set by `switch-properties` and also
|
|
4
|
+
* writes the auth cookie on the parent domain.
|
|
5
|
+
*/
|
|
6
|
+
export const LEGACY_REFRESH_TOKEN_PATH = '/api/vtexid/refreshtoken/webstore'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Authenticator refresh route, recommended for the B2B flow only. It keeps the
|
|
10
|
+
* contract claims (`customerId`, `unitId`, `isRepresentative`) and writes the
|
|
11
|
+
* auth cookie only on the request host.
|
|
12
|
+
*/
|
|
13
|
+
export const AUTHENTICATOR_REFRESH_TOKEN_PATH =
|
|
14
|
+
'/api/authenticator/v1/refreshtoken/webstore'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Every path `vid_rt` can be scoped to. VTEX ID writes it only on the legacy
|
|
18
|
+
* path; Authenticator writes it on all three. The browser only sends it to
|
|
19
|
+
* these paths, so it never reaches any other route.
|
|
20
|
+
*/
|
|
21
|
+
export const VID_RT_PATHS = [
|
|
22
|
+
LEGACY_REFRESH_TOKEN_PATH,
|
|
23
|
+
'/api/authenticator/refreshtoken/webstore',
|
|
24
|
+
AUTHENTICATOR_REFRESH_TOKEN_PATH,
|
|
25
|
+
] as const
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import { useEffect, useState } from 'react'
|
|
2
|
-
import {
|
|
2
|
+
import { shouldBypassRefreshTokenBrowser } from 'src/utils/shouldBypassRefreshToken'
|
|
3
3
|
import { logoutAndClearSession, sessionStore } from '../session'
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
getNextRefreshAfter,
|
|
6
|
+
isB2BSession,
|
|
7
|
+
isRefreshTokenSuccessful,
|
|
8
|
+
refreshTokenRequest,
|
|
9
|
+
} from './refreshToken'
|
|
5
10
|
|
|
6
11
|
export const useRefreshToken = (
|
|
7
12
|
needsRefreshToken?: boolean,
|
|
@@ -13,13 +18,12 @@ export const useRefreshToken = (
|
|
|
13
18
|
const handleRefreshTokenAndUpdateSession = async () => {
|
|
14
19
|
if (!needsRefreshToken) return
|
|
15
20
|
|
|
16
|
-
// The refresh-token endpoint
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
// which would also wipe the manually injected `VtexIdclientAutCookie_<account>`
|
|
21
|
+
// The refresh-token endpoint relies on VTEX ID, which neither localhost
|
|
22
|
+
// nor WebOps previews proxy, so the refresh always fails there — which
|
|
23
|
+
// would also wipe the manually injected `VtexIdclientAutCookie_<account>`
|
|
20
24
|
// via `logoutAndClearSession`. Bail out and fall back to the static 403
|
|
21
25
|
// view so developers can keep testing logged-in flows.
|
|
22
|
-
if (
|
|
26
|
+
if (shouldBypassRefreshTokenBrowser()) {
|
|
23
27
|
setShouldShow403(true)
|
|
24
28
|
return
|
|
25
29
|
}
|
|
@@ -27,13 +31,13 @@ export const useRefreshToken = (
|
|
|
27
31
|
const currentSession = sessionStore.read() ?? sessionStore.readInitial()
|
|
28
32
|
|
|
29
33
|
try {
|
|
30
|
-
const result = await refreshTokenRequest(
|
|
34
|
+
const result = await refreshTokenRequest({
|
|
35
|
+
isB2B: isB2BSession(currentSession),
|
|
36
|
+
})
|
|
31
37
|
|
|
32
38
|
if (isRefreshTokenSuccessful(result)) {
|
|
33
39
|
// Update session with new refreshAfter timestamp
|
|
34
|
-
const refreshAfter =
|
|
35
|
-
Math.floor(new Date(result?.refreshAfter).getTime() / 1000)
|
|
36
|
-
)
|
|
40
|
+
const refreshAfter = getNextRefreshAfter(result)
|
|
37
41
|
|
|
38
42
|
sessionStore.set({
|
|
39
43
|
...currentSession,
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a product request failed because the product is not available to
|
|
3
|
+
* the shopper. A non-2xx BFF response reaches the client as `{ status }` (see
|
|
4
|
+
* `ParseInvalidRequest`), while a 2xx one keeps the GraphQL `extensions`.
|
|
5
|
+
*/
|
|
6
|
+
export const isProductNotFound = (error: unknown) => {
|
|
7
|
+
const { status, extensions } = (error ?? {}) as {
|
|
8
|
+
status?: number
|
|
9
|
+
extensions?: { type?: string }
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
return status === 404 || extensions?.type === 'NotFoundError'
|
|
13
|
+
}
|
|
@@ -69,6 +69,69 @@ export const query = gql(`
|
|
|
69
69
|
|
|
70
70
|
const getKey = (object: any) => JSON.stringify(object)
|
|
71
71
|
|
|
72
|
+
const isDeliveryPromiseEnabled = storeConfig.deliveryPromise?.enabled ?? false
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Cache-only keys that scope gallery pages to the shopper context. They make
|
|
76
|
+
* the local cache comparison (and the SWR key) miss when a page was loaded for
|
|
77
|
+
* another context (e.g. the anonymous SSG page). They are not declared by the
|
|
78
|
+
* operation, so the API ignores them, but they are still sent with the
|
|
79
|
+
* variables, which go in the URL because this query is a GET operation.
|
|
80
|
+
* - `_postalCode`: pages are invalidated on region change. Only applied when
|
|
81
|
+
* deliveryPromise is enabled to avoid unnecessary cache fragmentation.
|
|
82
|
+
* - `_contract`: the B2B contract assortment is applied server-side from the
|
|
83
|
+
* session cookie, so it never shows up in the query variables. Keyed off
|
|
84
|
+
* the presence of a `b2b` session (not `customerId`'s value) because
|
|
85
|
+
* `customerId` can resolve to an empty string for a legitimate B2B buyer
|
|
86
|
+
* (see `buildB2bSession` in `@faststore/api`). The fallback chain still
|
|
87
|
+
* forces the re-fetch for them: the real `customerId`, when present, keeps
|
|
88
|
+
* invalidating the cache on contract switch; otherwise the organizational
|
|
89
|
+
* unit (`unitId`) tells buyers of different units apart; a generic marker is
|
|
90
|
+
* the last resort. Since the value ends up in the URL, it is hashed so the
|
|
91
|
+
* raw contract or unit id is not written to access, CDN or RUM logs as is.
|
|
92
|
+
* These ids are not secrets (the session already hands them to the
|
|
93
|
+
* browser), so the hash is cosmetic, not a privacy control: it is unkeyed
|
|
94
|
+
* and a guessable id can be recovered from it. The URL still differs per
|
|
95
|
+
* contract, so a contract page is never served from the anonymous page's
|
|
96
|
+
* cache entry.
|
|
97
|
+
*/
|
|
98
|
+
const getShopperScope = ({
|
|
99
|
+
postalCode,
|
|
100
|
+
contract,
|
|
101
|
+
}: {
|
|
102
|
+
postalCode?: string | null
|
|
103
|
+
contract?: string | null
|
|
104
|
+
}) => ({
|
|
105
|
+
...(isDeliveryPromiseEnabled && { _postalCode: postalCode ?? '' }),
|
|
106
|
+
...(contract && { _contract: hashContractScope(contract) }),
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
/** The id a B2B session is scoped by, or `null` outside a B2B session. */
|
|
110
|
+
const getContractScope = (
|
|
111
|
+
b2b?: { customerId?: string | null; unitId?: string | null } | null
|
|
112
|
+
) => (b2b ? b2b.customerId || b2b.unitId || 'b2b' : null)
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* 53-bit string hash (cyrb53): wide enough that two contracts in the same
|
|
116
|
+
* browser never share a cache key in practice, without exposing the raw id.
|
|
117
|
+
*/
|
|
118
|
+
export function hashContractScope(value: string) {
|
|
119
|
+
let h1 = 0xdeadbeef
|
|
120
|
+
let h2 = 0x41c6ce57
|
|
121
|
+
for (const char of value) {
|
|
122
|
+
const code = char.codePointAt(0) ?? 0
|
|
123
|
+
h1 = Math.imul(h1 ^ code, 2654435761)
|
|
124
|
+
h2 = Math.imul(h2 ^ code, 1597334677)
|
|
125
|
+
}
|
|
126
|
+
h1 =
|
|
127
|
+
Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^
|
|
128
|
+
Math.imul(h2 ^ (h2 >>> 13), 3266489909)
|
|
129
|
+
h2 =
|
|
130
|
+
Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^
|
|
131
|
+
Math.imul(h1 ^ (h1 >>> 13), 3266489909)
|
|
132
|
+
return (4294967296 * (2097151 & h2) + (h1 >>> 0)).toString(36)
|
|
133
|
+
}
|
|
134
|
+
|
|
72
135
|
interface UseCreateUseGalleryPageProps {
|
|
73
136
|
initialPages: ClientManyProductsQueryWithSearchIdQuery
|
|
74
137
|
serverManyProductsVariables: ClientManyProductsQueryWithSearchIdQueryVariables
|
|
@@ -82,21 +145,17 @@ export const useCreateUseGalleryPage = (
|
|
|
82
145
|
) => {
|
|
83
146
|
const initialPages = params?.initialPages?.search ? [params.initialPages] : []
|
|
84
147
|
|
|
85
|
-
// Seed the cache with the same
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
|
|
148
|
+
// Seed the cache with the same key shape that useGalleryPage uses, so
|
|
149
|
+
// hasSameVariables matches on first mount and avoids an unnecessary re-fetch
|
|
150
|
+
// of the SSR-loaded page. The SSG page is built without a buyer, so it is
|
|
151
|
+
// seeded without a contract: B2B buyers re-fetch it with their assortment.
|
|
89
152
|
const { postalCode } = useSession()
|
|
90
153
|
const initialVariables = params?.serverManyProductsVariables
|
|
91
154
|
? [
|
|
92
|
-
getKey(
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
_postalCode: postalCode ?? '',
|
|
97
|
-
}
|
|
98
|
-
: params.serverManyProductsVariables
|
|
99
|
-
),
|
|
155
|
+
getKey({
|
|
156
|
+
...params.serverManyProductsVariables,
|
|
157
|
+
...getShopperScope({ postalCode }),
|
|
158
|
+
}),
|
|
100
159
|
]
|
|
101
160
|
: []
|
|
102
161
|
|
|
@@ -113,7 +172,7 @@ export const useCreateUseGalleryPage = (
|
|
|
113
172
|
itemsPerPage,
|
|
114
173
|
} = useSearch()
|
|
115
174
|
|
|
116
|
-
const { postalCode, isValidating: isSessionValidating } = useSession()
|
|
175
|
+
const { postalCode, b2b, isValidating: isSessionValidating } = useSession()
|
|
117
176
|
|
|
118
177
|
const localizedVariables = useLocalizedVariables({
|
|
119
178
|
first: itemsPerPage,
|
|
@@ -123,15 +182,15 @@ export const useCreateUseGalleryPage = (
|
|
|
123
182
|
selectedFacets,
|
|
124
183
|
})
|
|
125
184
|
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
|
|
185
|
+
// Keyed on the contract id, not the `b2b` object, which is rebuilt on
|
|
186
|
+
// every session update.
|
|
187
|
+
const contract = getContractScope(b2b)
|
|
129
188
|
const localizedVariablesWithRegion = useMemo(
|
|
130
|
-
() =>
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
[localizedVariables, postalCode]
|
|
189
|
+
() => ({
|
|
190
|
+
...localizedVariables,
|
|
191
|
+
...getShopperScope({ postalCode, contract }),
|
|
192
|
+
}),
|
|
193
|
+
[localizedVariables, postalCode, contract]
|
|
135
194
|
)
|
|
136
195
|
|
|
137
196
|
const hasSameVariables = deepEquals(
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { useRouter } from 'next/router'
|
|
2
|
+
import { useEffect, useRef } from 'react'
|
|
3
|
+
|
|
4
|
+
import { isProductNotFound } from './isProductNotFound'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The PDP is statically generated without the shopper's session, so an
|
|
8
|
+
* assortment restriction (e.g. a B2B contract limited to a collection) is only
|
|
9
|
+
* enforced when the client refetches the product. Replaces the PDP with the
|
|
10
|
+
* store's 404 page when that refetch says the product is not available to the
|
|
11
|
+
* shopper. The URL becomes `/404`: passing the product path as the `as`
|
|
12
|
+
* argument would make Next load the PDP's data for the 404 page, which expects
|
|
13
|
+
* a different shape and crashes.
|
|
14
|
+
*
|
|
15
|
+
* Applies to every shopper: the API rejects the product from the
|
|
16
|
+
* `vtex_segment` assortment, the same rule Intelligent Search applies to
|
|
17
|
+
* listings, whether or not the session is B2B.
|
|
18
|
+
*
|
|
19
|
+
* Returns whether the PDP must keep its loading state instead of the product's
|
|
20
|
+
* price and buy button while the redirect is pending. Without it, the failed
|
|
21
|
+
* refetch falls back to the static product and its price shows until the 404
|
|
22
|
+
* page loads.
|
|
23
|
+
*/
|
|
24
|
+
export const useProductNotFoundRedirect = (error: unknown) => {
|
|
25
|
+
const router = useRouter()
|
|
26
|
+
const shouldRedirect = isProductNotFound(error)
|
|
27
|
+
// The router changes identity on route events, which re-runs the effect.
|
|
28
|
+
// Navigate once per not-found so a pending redirect is not restarted.
|
|
29
|
+
const hasRedirected = useRef(false)
|
|
30
|
+
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
if (!shouldRedirect) {
|
|
33
|
+
hasRedirected.current = false
|
|
34
|
+
return
|
|
35
|
+
}
|
|
36
|
+
if (hasRedirected.current) return
|
|
37
|
+
|
|
38
|
+
hasRedirected.current = true
|
|
39
|
+
router.replace('/404')
|
|
40
|
+
}, [shouldRedirect, router])
|
|
41
|
+
|
|
42
|
+
return shouldRedirect
|
|
43
|
+
}
|
|
@@ -6,8 +6,11 @@ import type {
|
|
|
6
6
|
ClientProductQueryQueryVariables,
|
|
7
7
|
} from '@generated/graphql'
|
|
8
8
|
|
|
9
|
+
import { request } from '../graphql/request'
|
|
10
|
+
import { onErrorRetry } from '../graphql/retryPolicy'
|
|
9
11
|
import { useQuery } from '../graphql/useQuery'
|
|
10
12
|
import { useSession } from '../session'
|
|
13
|
+
import { isProductNotFound } from './isProductNotFound'
|
|
11
14
|
|
|
12
15
|
const query = gql(`
|
|
13
16
|
query ClientProductQuery($locator: [IStoreSelectedFacet!]!) {
|
|
@@ -18,6 +21,19 @@ const query = gql(`
|
|
|
18
21
|
}
|
|
19
22
|
`)
|
|
20
23
|
|
|
24
|
+
const nextTick = () => new Promise<void>((resolve) => setTimeout(resolve))
|
|
25
|
+
|
|
26
|
+
const fetchProduct = async <T extends ClientProductQueryQuery>(
|
|
27
|
+
variables: ClientProductQueryQueryVariables
|
|
28
|
+
) => {
|
|
29
|
+
await nextTick()
|
|
30
|
+
|
|
31
|
+
return (await request<
|
|
32
|
+
ClientProductQueryQuery & T,
|
|
33
|
+
ClientProductQueryQueryVariables
|
|
34
|
+
>(query, variables)) as ClientProductQueryQuery & T
|
|
35
|
+
}
|
|
36
|
+
|
|
21
37
|
export const useProductQuery = <T extends ClientProductQueryQuery>(
|
|
22
38
|
productID: string,
|
|
23
39
|
fallbackData?: T
|
|
@@ -45,5 +61,18 @@ export const useProductQuery = <T extends ClientProductQueryQuery>(
|
|
|
45
61
|
>(query, variables, {
|
|
46
62
|
fallbackData,
|
|
47
63
|
revalidateOnMount: true,
|
|
64
|
+
// `useQuery`'s default fetcher never settles when `request` throws, so SWR
|
|
65
|
+
// never exposes `error` (e.g. the B2B assortment 404 the PDP reacts to).
|
|
66
|
+
// This fetcher keeps the same deferral but forwards the rejection.
|
|
67
|
+
fetcher: () => fetchProduct<T>(variables),
|
|
68
|
+
// A not-found is the answer (the product is not available to this
|
|
69
|
+
// shopper), not a transient failure, so it is not retried. It is matched
|
|
70
|
+
// the same way the PDP redirect matches it. Other errors follow the shared
|
|
71
|
+
// retry policy.
|
|
72
|
+
onErrorRetry: (error, key, config, revalidate, opts) => {
|
|
73
|
+
if (isProductNotFound(error)) return
|
|
74
|
+
|
|
75
|
+
onErrorRetry(error, key, config, revalidate, opts)
|
|
76
|
+
},
|
|
48
77
|
})
|
|
49
78
|
}
|