@faststore/core 4.10.1-dev.6 → 4.10.1-dev.7

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.
Files changed (60) hide show
  1. package/.turbo/turbo-generate.log +3 -3
  2. package/.turbo/turbo-test.log +176 -167
  3. package/@generated/graphql.ts +1 -0
  4. package/@generated/schema.graphql +1 -1
  5. package/CHANGELOG.md +29 -0
  6. package/package.json +6 -6
  7. package/src/components/account/Drawer/OrganizationDrawer/OrganizationDrawer.tsx +3 -3
  8. package/src/components/account/Drawer/OrganizationDrawer/OrganizationDrawerBody.tsx +5 -7
  9. package/src/components/account/Drawer/OrganizationSignInButton/OrganizationSignInButton.tsx +4 -1
  10. package/src/components/account/Layout/Layout.tsx +5 -7
  11. package/src/components/account/Menu/Menu.tsx +1 -1
  12. package/src/components/navigation/Navbar/Navbar.tsx +3 -3
  13. package/src/components/navigation/Navbar/NavbarAccountArea.tsx +5 -5
  14. package/src/components/navigation/NavbarSlider/NavbarSlider.tsx +3 -3
  15. package/src/experimental/myAccountCmsServerSideProps.ts +3 -3
  16. package/src/experimental/myAccountServerSideProps.ts +4 -4
  17. package/src/pages/[slug]/p.tsx +11 -2
  18. package/src/pages/api/fs/logout.ts +18 -10
  19. package/src/pages/api/fs/switch-contract.ts +170 -21
  20. package/src/pages/api/graphql.ts +4 -3
  21. package/src/pages/pvt/account/cards/index.tsx +5 -5
  22. package/src/pages/pvt/account/orders/[id].tsx +5 -5
  23. package/src/pages/pvt/account/orders/index.tsx +5 -5
  24. package/src/pages/pvt/account/profile.tsx +5 -5
  25. package/src/pages/pvt/account/quotes/index.tsx +5 -5
  26. package/src/pages/pvt/account/security.tsx +5 -5
  27. package/src/pages/pvt/account/user-details.tsx +6 -6
  28. package/src/sdk/account/getB2BSessionClaims.ts +1 -1
  29. package/src/sdk/account/getIsB2BBuyer.ts +28 -0
  30. package/src/sdk/account/refreshToken.ts +89 -11
  31. package/src/sdk/account/refreshTokenPaths.ts +25 -0
  32. package/src/sdk/account/useRefreshToken.ts +10 -5
  33. package/src/sdk/product/isProductNotFound.ts +13 -0
  34. package/src/sdk/product/useProductNotFoundRedirect.ts +43 -0
  35. package/src/sdk/product/useProductQuery.ts +29 -0
  36. package/src/sdk/session/index.ts +4 -4
  37. package/src/utils/getCheckoutParentDomain.ts +53 -0
  38. package/src/utils/trustedForwardedHost.ts +18 -0
  39. package/test/components/account/Layout.test.tsx +1 -1
  40. package/test/components/account/Menu.browser.test.tsx +7 -5
  41. package/test/components/account/OrganizationDrawer.browser.test.tsx +13 -13
  42. package/test/components/account/OrganizationDrawerBody.test.tsx +3 -3
  43. package/test/components/account/OrganizationSignInButton.browser.test.tsx +19 -4
  44. package/test/components/navigation/Navbar.browser.test.tsx +3 -3
  45. package/test/components/navigation/NavbarAccountArea.browser.test.tsx +5 -5
  46. package/test/components/navigation/NavbarSlider.browser.test.tsx +3 -3
  47. package/test/experimental/myAccountCmsServerSideProps.test.ts +5 -5
  48. package/test/pages/api/logout.test.ts +106 -0
  49. package/test/pages/api/switch-contract.test.ts +443 -15
  50. package/test/pages/pvt/account/cards/index.test.ts +3 -3
  51. package/test/sdk/account/getIsB2BBuyer.test.ts +52 -0
  52. package/test/sdk/account/refreshToken.browser.test.ts +10 -1
  53. package/test/sdk/account/refreshToken.test.ts +150 -18
  54. package/test/sdk/account/useRefreshToken.browser.test.ts +4 -1
  55. package/test/sdk/product/useProductNotFoundRedirect.browser.test.ts +83 -0
  56. package/test/sdk/product/useProductQuery.browser.test.ts +154 -0
  57. package/test/sdk/session/refreshTokenRoute.browser.test.ts +106 -0
  58. package/test/sdk/session/validateSession.browser.test.ts +4 -1
  59. package/test/utils/trustedForwardedHost.test.ts +21 -0
  60. package/src/sdk/account/getIsRepresentative.ts +0 -20
@@ -1,9 +1,17 @@
1
1
  import { parse } from 'cookie'
2
2
  import type { NextApiHandler, NextApiRequest, NextApiResponse } from 'next'
3
+ import { isIP } from 'node:net'
3
4
 
4
5
  import storeConfig from 'discovery.config'
5
6
  import { expireCookieServer, getCookieDomains } from 'src/utils/clearCookies'
7
+ import { getCheckoutParentDomain } from 'src/utils/getCheckoutParentDomain'
6
8
  import { getRequestHostname } from 'src/utils/getRequestHostname'
9
+ import {
10
+ isCookieSafeHostname,
11
+ isHostAllowed,
12
+ removeCookieDomain,
13
+ singleForwardedHost,
14
+ } from 'src/utils/trustedForwardedHost'
7
15
 
8
16
  /**
9
17
  * Cookies holding the checkout orderForm of the previous commercial context.
@@ -42,6 +50,23 @@ const isAuthCookie = (cookie: unknown): cookie is VtexAuthCookie => {
42
50
  return Boolean(candidate?.Name?.trim() && candidate?.Value)
43
51
  }
44
52
 
53
+ /**
54
+ * A non-positive `expiresIn` would turn into `Max-Age=0`, which expires the
55
+ * credential the response is supposed to install — logging the buyer out while
56
+ * the route reports success. Anything that is not a positive integer falls back
57
+ * to the default.
58
+ */
59
+ const resolveMaxAge = (expiresIn: unknown): number =>
60
+ typeof expiresIn === 'number' && Number.isInteger(expiresIn) && expiresIn > 0
61
+ ? expiresIn
62
+ : DEFAULT_AUTH_COOKIE_MAX_AGE
63
+
64
+ /**
65
+ * Uses the forwarded protocol when a proxy sets it, otherwise the connection
66
+ * itself. A non-local hostname alone does not imply https: a `Secure` cookie
67
+ * sent over plain http is dropped by the browser while the route reports
68
+ * success.
69
+ */
45
70
  const isSecureRequest = (request: NextApiRequest): boolean => {
46
71
  const forwarded = request.headers['x-forwarded-proto']
47
72
  const proto = Array.isArray(forwarded) ? forwarded[0] : forwarded
@@ -50,41 +75,162 @@ const isSecureRequest = (request: NextApiRequest): boolean => {
50
75
  return proto.split(',')[0].trim() === 'https'
51
76
  }
52
77
 
53
- const hostname = getRequestHostname(request.headers.host)
78
+ const socket = request.socket
79
+ return Boolean(socket && 'encrypted' in socket && socket.encrypted)
80
+ }
81
+
82
+ /**
83
+ * Every parent of `hostname` with at least two labels, closest first:
84
+ * `a.b.store.com` gives `b.store.com` and `store.com`. A parent that is a
85
+ * public suffix (e.g. `com.br`) is rejected by the browser, so expiring a
86
+ * cookie there is a no-op.
87
+ */
88
+ const getParentDomains = (hostname: string): string[] => {
89
+ const labels = hostname.split('.')
54
90
 
55
- return hostname !== 'localhost' && hostname !== '127.0.0.1'
91
+ return labels
92
+ .slice(1, -1)
93
+ .map((_, index) => labels.slice(index + 1).join('.'))
56
94
  }
57
95
 
58
96
  /**
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.
97
+ * Hosts where the auth cookies stay host-only: WebOps previews, whose ingress
98
+ * does not pass the browser host in `Host`, and local development.
99
+ * `{account}.vtex.app` is a production domain that receives the browser host,
100
+ * so it gets a Domain like any custom domain. `/api/graphql` keeps its wider
101
+ * `.vtex.app` list for the cookies it relays from upstream.
63
102
  */
64
- const resolveMaxAge = (expiresIn: unknown): number =>
65
- typeof expiresIn === 'number' && Number.isInteger(expiresIn) && expiresIn > 0
66
- ? expiresIn
67
- : DEFAULT_AUTH_COOKIE_MAX_AGE
103
+ const HOST_ONLY_AUTH_COOKIE_SUFFIXES = [
104
+ 'localhost',
105
+ '.localhost',
106
+ '.preview.vtex.app',
107
+ ]
108
+
109
+ const isHostOnlyCookieHost = (hostname: string | null): boolean =>
110
+ !!hostname && isHostAllowed(hostname, HOST_ONLY_AUTH_COOKIE_SUFFIXES)
68
111
 
69
112
  /**
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.
113
+ * Domain for the auth cookies, or `undefined` to write them host-only.
114
+ *
115
+ * Previews and local hosts stay host-only: behind the preview ingress `Host`
116
+ * is not the host the browser requested, and a mismatched Domain makes the browser drop
117
+ * the cookie while the route reports success. `x-forwarded-host` only decides
118
+ * that the cookie is host-only; it never chooses the Domain. IP literals and
119
+ * hosts that cannot be written verbatim into the attribute (including a
120
+ * trailing-dot FQDN) are host-only too.
121
+ */
122
+ const resolveAuthCookieDomain = (
123
+ request: NextApiRequest
124
+ ): string | undefined => {
125
+ const hostname = getRequestHostname(request.headers.host)
126
+ const forwardedHost = getRequestHostname(
127
+ singleForwardedHost(request.headers['x-forwarded-host'])
128
+ )
129
+
130
+ if (
131
+ !hostname ||
132
+ isHostOnlyCookieHost(hostname) ||
133
+ isHostOnlyCookieHost(forwardedHost) ||
134
+ isIP(hostname) !== 0 ||
135
+ hostname.endsWith('.') ||
136
+ !isCookieSafeHostname(hostname)
137
+ ) {
138
+ return undefined
139
+ }
140
+
141
+ return hostname
142
+ }
143
+
144
+ /**
145
+ * Mirrors the attributes VTEX ID uses when it sets these cookies at login and
146
+ * refresh (`Domain=<host>; Path=/; Secure; SameSite=None; HttpOnly`).
147
+ *
148
+ * Browsers tell cookies apart by name, domain and path, and a cookie with a
149
+ * Domain attribute never matches a host-only one. Writing the contract token
150
+ * host-only would sit next to VTEX ID's cookie instead of replacing it, leaving
151
+ * two `VtexIdclientAutCookie_<account>` with different contracts. Each reader
152
+ * then picks a different one. Any host-only copy left by earlier versions is
153
+ * expired in the same batch.
154
+ *
155
+ * VTEX ID's refresh also writes the cookie on the parent domain (e.g.
156
+ * `Domain=store.com` for `www.store.com`). Parent copies are expired, so the
157
+ * only auth cookie left holds the switched contract. The exception is the
158
+ * parent shared with a checkout on a sibling host (`secure.store.com`): the
159
+ * token is written there too, with the same value as the host copy, so
160
+ * checkout keeps the session after the switch.
161
+ *
162
+ * The Domain-scoped cookie is always `Secure`, like VTEX ID's, because the
163
+ * forwarded protocol is not reliable behind the CDN. The host-only fallback
164
+ * follows the request protocol so plain-http local hosts keep working.
74
165
  */
75
166
  const buildAuthCookies = (
76
167
  payload: SwitchPropertiesResponse,
168
+ domain: string | undefined,
77
169
  secure: boolean
78
170
  ): string[] => {
79
- const maxAge = resolveMaxAge(payload.expiresIn)
80
- const secureAttr = secure ? '; Secure' : ''
171
+ const checkoutParent = domain ? getCheckoutParentDomain(domain) : undefined
172
+ const staleScopes: Array<string | undefined> = domain
173
+ ? [
174
+ undefined,
175
+ ...getParentDomains(domain).filter(
176
+ (parent) => parent !== checkoutParent
177
+ ),
178
+ ]
179
+ : []
180
+ const writeScopes = checkoutParent ? [domain, checkoutParent] : [domain]
181
+ const attributes = (scope: string | undefined) =>
182
+ [
183
+ scope && `Domain=${scope}`,
184
+ 'Path=/',
185
+ `Max-Age=${resolveMaxAge(payload.expiresIn)}`,
186
+ 'HttpOnly',
187
+ (scope || secure) && 'Secure',
188
+ `SameSite=${scope ? 'None' : 'Lax'}`,
189
+ ]
190
+ .filter(Boolean)
191
+ .join('; ')
81
192
 
82
193
  return [payload.authCookie, payload.accountAuthCookie]
83
194
  .filter(isAuthCookie)
84
- .map(
85
- (cookie) =>
86
- `${cookie.Name}=${cookie.Value}; Path=/; Max-Age=${maxAge}; SameSite=Lax${secureAttr}`
87
- )
195
+ .flatMap((cookie) => [
196
+ ...staleScopes.map((staleDomain) =>
197
+ expireCookieServer({
198
+ name: cookie.Name,
199
+ path: '/',
200
+ domain: staleDomain,
201
+ })
202
+ ),
203
+ ...writeScopes.map(
204
+ (scope) => `${cookie.Name}=${cookie.Value}; ${attributes(scope)}`
205
+ ),
206
+ ])
207
+ }
208
+
209
+ /**
210
+ * `switch-properties` also issues `vid_rt` (the refresh token) on the
211
+ * Authenticator refresh paths. It is called server-side on
212
+ * `<account>.myvtex.com`, so its Domain points there and the browser would
213
+ * reject it. Re-scope it like the auth cookies so the next B2B refresh, which
214
+ * goes through the Authenticator route, finds it.
215
+ *
216
+ * Only relayed with `experimental.refreshToken` on: otherwise nothing uses it,
217
+ * and `/api/fs/logout` does not expire it either, so it would outlive logout.
218
+ */
219
+ const relayRefreshTokenCookies = (
220
+ headers: Headers | undefined,
221
+ domain: string | undefined
222
+ ): string[] => {
223
+ if (!storeConfig.experimental?.refreshToken) {
224
+ return []
225
+ }
226
+
227
+ return (headers?.getSetCookie?.() ?? [])
228
+ .filter((cookie) => cookie.trimStart().startsWith('vid_rt='))
229
+ .map((cookie) => {
230
+ const hostOnly = removeCookieDomain(cookie)
231
+
232
+ return domain ? `${hostOnly}; Domain=${domain}` : hostOnly
233
+ })
88
234
  }
89
235
 
90
236
  const buildOrderFormExpiryCookies = (request: NextApiRequest): string[] => {
@@ -167,8 +313,11 @@ const handler: NextApiHandler<SwitchContractResponse> = async (
167
313
  return
168
314
  }
169
315
 
316
+ const domain = resolveAuthCookieDomain(request)
317
+
170
318
  response.setHeader('Set-Cookie', [
171
- ...buildAuthCookies(payload, isSecureRequest(request)),
319
+ ...buildAuthCookies(payload, domain, isSecureRequest(request)),
320
+ ...relayRefreshTokenCookies(switchResponse.headers, domain),
172
321
  ...buildOrderFormExpiryCookies(request),
173
322
  ])
174
323
 
@@ -13,6 +13,7 @@ import { getJWTAutCookie } from 'src/utils/getCookie'
13
13
  import { getRequestHostname } from 'src/utils/getRequestHostname'
14
14
  import { shouldBypassRefreshTokenForRequest } from 'src/utils/shouldBypassRefreshToken'
15
15
  import {
16
+ HOST_ONLY_COOKIE_HOST_SUFFIXES,
16
17
  isHostAllowed,
17
18
  removeCookieDomain,
18
19
  singleForwardedHost,
@@ -25,7 +26,6 @@ const OTELLogger = logger('@faststore/core')
25
26
 
26
27
  const DEFAULT_MAX_AGE = 5 * 60 // 5 minutes
27
28
  const DEFAULT_STALE_WHILE_REVALIDATE = 60 * 60 // 1 hour
28
- const ALLOWED_HOST_SUFFIXES = ['localhost', '.vtex.app', '.localhost']
29
29
 
30
30
  /**
31
31
  * Recovers a FastStoreError from an execution error, whether it is the error
@@ -88,7 +88,8 @@ const normalizeSetCookieDomain = ({
88
88
  OTELLogger('warn', 'Ignoring multi-value or malformed x-forwarded-host')
89
89
  }
90
90
  const host =
91
- forwardedHost && isHostAllowed(forwardedHost, ALLOWED_HOST_SUFFIXES)
91
+ forwardedHost &&
92
+ isHostAllowed(forwardedHost, HOST_ONLY_COOKIE_HOST_SUFFIXES)
92
93
  ? forwardedHost
93
94
  : getRequestHostname(request.headers.host)
94
95
  if (!host) {
@@ -97,7 +98,7 @@ const normalizeSetCookieDomain = ({
97
98
  const cookieDomain = domainMatch[1]
98
99
 
99
100
  if (
100
- !isHostAllowed(host, ALLOWED_HOST_SUFFIXES) ||
101
+ !isHostAllowed(host, HOST_ONLY_COOKIE_HOST_SUFFIXES) ||
101
102
  !shouldReplaceCookieDomain({ cookieDomain, host })
102
103
  ) {
103
104
  return setCookie
@@ -21,7 +21,7 @@ import type { MyAccountProps } from 'src/experimental/myAccountServerSideProps'
21
21
  import type { AccountCardsPageData } from 'src/sdk/account/accountPageContext'
22
22
  import type { AccountNavigationLabels } from 'src/sdk/account/getMyAccountRoutes'
23
23
  import { getB2BSessionClaims } from 'src/sdk/account/getB2BSessionClaims'
24
- import { getIsRepresentative } from 'src/sdk/account/getIsRepresentative'
24
+ import { getIsB2BBuyer } from 'src/sdk/account/getIsB2BBuyer'
25
25
  import { execute } from 'src/server'
26
26
  import { injectGlobalSections } from 'src/server/cms/global'
27
27
  import { extractAccountNavigationData } from 'src/server/cms/myAccountDefaultSections'
@@ -72,7 +72,7 @@ export default function ListCardsPage({
72
72
  navigationLabels,
73
73
  accountPageData,
74
74
  accountName,
75
- isRepresentative,
75
+ isB2BBuyer,
76
76
  }: ListCardsPageProps) {
77
77
  const { sections: globalSections, settings: globalSettings } =
78
78
  globalSectionsProp ?? {}
@@ -89,7 +89,7 @@ export default function ListCardsPage({
89
89
  <NextSeo noindex nofollow />
90
90
 
91
91
  <Layout
92
- isRepresentative={isRepresentative}
92
+ isB2BBuyer={isB2BBuyer}
93
93
  accountName={accountName}
94
94
  navigationLabels={navigationLabels}
95
95
  >
@@ -109,7 +109,7 @@ const getServerSidePropsBase: GetServerSideProps<
109
109
  Record<string, string>,
110
110
  Locator
111
111
  > = async (context) => {
112
- const isRepresentative = getIsRepresentative({
112
+ const isB2BBuyer = getIsB2BBuyer({
113
113
  headers: context.req.headers as Record<string, string>,
114
114
  account: storeConfig.api.storeId,
115
115
  })
@@ -249,7 +249,7 @@ const getServerSidePropsBase: GetServerSideProps<
249
249
  hasError,
250
250
  },
251
251
  pageSections,
252
- isRepresentative,
252
+ isB2BBuyer,
253
253
  },
254
254
  }
255
255
  }
@@ -23,7 +23,7 @@ import storeConfig from 'discovery.config'
23
23
  import { getGlobalSectionsData } from 'src/components/cms/GlobalSections'
24
24
  import { default as AfterSection } from 'src/customizations/src/myAccount/extensions/orders/[id]/after'
25
25
  import { default as BeforeSection } from 'src/customizations/src/myAccount/extensions/orders/[id]/before'
26
- import { getIsRepresentative } from 'src/sdk/account/getIsRepresentative'
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'
@@ -51,7 +51,7 @@ export default function OrderDetailsPage({
51
51
  navigationLabels,
52
52
  accountPageData,
53
53
  accountName,
54
- isRepresentative,
54
+ isB2BBuyer,
55
55
  }: OrderDetailsPageProps) {
56
56
  const { sections: globalSections, settings: globalSettings } =
57
57
  globalSectionsProp ?? {}
@@ -68,7 +68,7 @@ export default function OrderDetailsPage({
68
68
  <NextSeo noindex nofollow />
69
69
 
70
70
  <Layout
71
- isRepresentative={isRepresentative}
71
+ isB2BBuyer={isB2BBuyer}
72
72
  accountName={accountName}
73
73
  navigationLabels={navigationLabels}
74
74
  >
@@ -313,7 +313,7 @@ const getServerSidePropsBase: GetServerSideProps<
313
313
  Record<string, string>,
314
314
  Locator
315
315
  > = async (context) => {
316
- const isRepresentative = getIsRepresentative({
316
+ const isB2BBuyer = getIsB2BBuyer({
317
317
  headers: context.req.headers as Record<string, string>,
318
318
  account: storeConfig.api.storeId,
319
319
  })
@@ -415,7 +415,7 @@ const getServerSidePropsBase: GetServerSideProps<
415
415
  ...(orderStatusLabels ? { orderStatusLabels } : {}),
416
416
  },
417
417
  pageSections,
418
- isRepresentative,
418
+ isB2BBuyer,
419
419
  },
420
420
  }
421
421
  }
@@ -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 { AccountOrdersListPageData } from 'src/sdk/account/accountPageContext'
24
24
  import type { AccountNavigationLabels } from 'src/sdk/account/getMyAccountRoutes'
25
- import { getIsRepresentative } from 'src/sdk/account/getIsRepresentative'
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'
@@ -52,7 +52,7 @@ export default function ListOrdersPage({
52
52
  navigationLabels,
53
53
  accountPageData,
54
54
  accountName,
55
- isRepresentative,
55
+ isB2BBuyer,
56
56
  }: ListOrdersPageProps) {
57
57
  const { sections: globalSections, settings: globalSettings } =
58
58
  globalSectionsProp ?? {}
@@ -69,7 +69,7 @@ export default function ListOrdersPage({
69
69
  <NextSeo noindex nofollow />
70
70
 
71
71
  <Layout
72
- isRepresentative={isRepresentative}
72
+ isB2BBuyer={isB2BBuyer}
73
73
  accountName={accountName}
74
74
  navigationLabels={navigationLabels}
75
75
  >
@@ -132,7 +132,7 @@ const getServerSidePropsBase: GetServerSideProps<
132
132
  Record<string, string>,
133
133
  Locator
134
134
  > = async (context) => {
135
- const isRepresentative = getIsRepresentative({
135
+ const isB2BBuyer = getIsB2BBuyer({
136
136
  headers: context.req.headers as Record<string, string>,
137
137
  account: storeConfig.api.storeId,
138
138
  })
@@ -265,7 +265,7 @@ const getServerSidePropsBase: GetServerSideProps<
265
265
  },
266
266
  },
267
267
  pageSections,
268
- isRepresentative,
268
+ isB2BBuyer,
269
269
  },
270
270
  }
271
271
  }
@@ -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 { AccountProfilePageData } from 'src/sdk/account/accountPageContext'
24
24
  import type { AccountNavigationLabels } from 'src/sdk/account/getMyAccountRoutes'
25
- import { getIsRepresentative } from 'src/sdk/account/getIsRepresentative'
25
+ import { getIsB2BBuyer } from 'src/sdk/account/getIsB2BBuyer'
26
26
  import { injectGlobalSections } from 'src/server/cms/global'
27
27
  import { extractAccountNavigationData } from 'src/server/cms/myAccountDefaultSections'
28
28
  import { fetchMyAccountPageContent } from 'src/server/cms/fetchMyAccountPageContent'
@@ -51,7 +51,7 @@ export default function Profile({
51
51
  navigationLabels,
52
52
  accountPageData,
53
53
  accountName,
54
- isRepresentative,
54
+ isB2BBuyer,
55
55
  }: ProfilePageProps) {
56
56
  const { sections: globalSections, settings: globalSettings } =
57
57
  globalSectionsProp ?? {}
@@ -68,7 +68,7 @@ export default function Profile({
68
68
  <NextSeo noindex nofollow />
69
69
 
70
70
  <Layout
71
- isRepresentative={isRepresentative}
71
+ isB2BBuyer={isB2BBuyer}
72
72
  accountName={accountName}
73
73
  navigationLabels={navigationLabels}
74
74
  >
@@ -108,7 +108,7 @@ const getServerSidePropsBase: GetServerSideProps<
108
108
  return { redirect }
109
109
  }
110
110
 
111
- const isRepresentative = getIsRepresentative({
111
+ const isB2BBuyer = getIsB2BBuyer({
112
112
  headers: context.req.headers as Record<string, string>,
113
113
  account: storeConfig.api.storeId,
114
114
  })
@@ -184,7 +184,7 @@ const getServerSidePropsBase: GetServerSideProps<
184
184
  },
185
185
  pageSections,
186
186
  navigationLabels: navigationData as AccountNavigationLabels,
187
- isRepresentative,
187
+ isB2BBuyer,
188
188
  },
189
189
  }
190
190
  }
@@ -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 { getIsRepresentative } from 'src/sdk/account/getIsRepresentative'
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
- isRepresentative,
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
- isRepresentative={isRepresentative}
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 isRepresentative = getIsRepresentative({
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
- isRepresentative,
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 { getIsRepresentative } from 'src/sdk/account/getIsRepresentative'
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
- isRepresentative,
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
- isRepresentative={isRepresentative}
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 isRepresentative = getIsRepresentative({
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
- isRepresentative,
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 { getIsRepresentative } from 'src/sdk/account/getIsRepresentative'
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
- isRepresentative,
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
- isRepresentative={isRepresentative}
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 isRepresentative = getIsRepresentative({
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 (!isRepresentative) {
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
- isRepresentative,
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 `getIsRepresentative` already decodes.
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
+ }