@faststore/core 4.8.1-dev.1 → 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 +126 -123
- 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 +6 -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/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/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/pages/api/fs/switch-contract.ts +194 -0
- package/src/sdk/account/changeContractToken.ts +19 -63
- package/src/sdk/account/clearPersistedSessionState.ts +13 -7
- package/src/sdk/account/useAvailableContracts.ts +1 -0
- package/src/sdk/account/useSwitchContract.ts +11 -3
- 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/OrganizationDrawerHeader.browser.test.tsx +2 -0
- package/test/components/account/OrganizationSignInButton.browser.test.tsx +49 -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/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/useSwitchContract.browser.test.ts +21 -0
- package/test/sdk/session/isSignInAreaResolved.test.ts +22 -0
|
@@ -2,6 +2,7 @@ import { SlideOver, useFadeEffect } from '@faststore/ui'
|
|
|
2
2
|
import { useEffect, useState } from 'react'
|
|
3
3
|
|
|
4
4
|
import { getStoreURL } from 'src/sdk/localization/useLocalizationConfig'
|
|
5
|
+
import { useAvailableContracts } from 'src/sdk/account/useAvailableContracts'
|
|
5
6
|
import { useSession } from 'src/sdk/session'
|
|
6
7
|
import {
|
|
7
8
|
expireCookieClient,
|
|
@@ -23,6 +24,8 @@ type OrganizationDrawerProps = {
|
|
|
23
24
|
isOpen: boolean
|
|
24
25
|
closeDrawer: () => void
|
|
25
26
|
isRepresentative: boolean
|
|
27
|
+
/** Open straight into the contract switcher (My Account "Switch" button). */
|
|
28
|
+
initialView?: OrganizationDrawerView
|
|
26
29
|
}
|
|
27
30
|
|
|
28
31
|
const clearBrowserStorageForCurrentDomain = async () => {
|
|
@@ -140,16 +143,17 @@ export const OrganizationDrawer = ({
|
|
|
140
143
|
isOpen,
|
|
141
144
|
closeDrawer,
|
|
142
145
|
isRepresentative,
|
|
146
|
+
initialView = 'menu',
|
|
143
147
|
}: OrganizationDrawerProps) => {
|
|
144
148
|
const { fade, fadeOut } = useFadeEffect()
|
|
145
149
|
const { b2b, person } = useSession()
|
|
146
|
-
const [view, setView] = useState<OrganizationDrawerView>(
|
|
150
|
+
const [view, setView] = useState<OrganizationDrawerView>(initialView)
|
|
147
151
|
|
|
148
152
|
useEffect(() => {
|
|
149
153
|
if (!isOpen) {
|
|
150
|
-
setView(
|
|
154
|
+
setView(initialView)
|
|
151
155
|
}
|
|
152
|
-
}, [isOpen])
|
|
156
|
+
}, [isOpen, initialView])
|
|
153
157
|
|
|
154
158
|
const contractName =
|
|
155
159
|
b2b?.contractName?.trim() ||
|
|
@@ -168,6 +172,16 @@ export const OrganizationDrawer = ({
|
|
|
168
172
|
const canSwitchContract = Boolean(b2b?.unitId)
|
|
169
173
|
const isOrgMember = Boolean(b2b?.unitId)
|
|
170
174
|
|
|
175
|
+
// Only fetch the contract list while the drawer is open for an org member,
|
|
176
|
+
// so nothing is requested on initial page render (TTFB budget).
|
|
177
|
+
const { contracts } = useAvailableContracts(isOpen && canSwitchContract)
|
|
178
|
+
const activeContractId = b2b?.customerId?.trim() ?? ''
|
|
179
|
+
const activeContract =
|
|
180
|
+
(activeContractId
|
|
181
|
+
? contracts.find((contract) => contract.id === activeContractId)
|
|
182
|
+
: undefined) ?? contracts.find((contract) => contract.isActive)
|
|
183
|
+
const isActiveContractDefault = Boolean(activeContract?.isDefault)
|
|
184
|
+
|
|
171
185
|
return (
|
|
172
186
|
<SlideOver
|
|
173
187
|
data-fs-organization-drawer
|
|
@@ -190,6 +204,7 @@ export const OrganizationDrawer = ({
|
|
|
190
204
|
onCloseDrawer={closeDrawer}
|
|
191
205
|
contractName={contractName}
|
|
192
206
|
contractUrl={contractUrl}
|
|
207
|
+
isDefault={isActiveContractDefault}
|
|
193
208
|
onChangeContract={
|
|
194
209
|
canSwitchContract ? () => setView('switch') : undefined
|
|
195
210
|
}
|
|
@@ -128,6 +128,10 @@
|
|
|
128
128
|
[data-fs-organization-drawer-header-default] {
|
|
129
129
|
flex-shrink: 0;
|
|
130
130
|
color: var(--fs-color-link);
|
|
131
|
+
|
|
132
|
+
// The Star symbol only declares stroke="currentColor", so without this
|
|
133
|
+
// the fill falls back to the SVG default (black).
|
|
134
|
+
fill: currentColor;
|
|
131
135
|
}
|
|
132
136
|
|
|
133
137
|
[data-fs-organization-drawer-header-change] {
|
|
@@ -331,6 +335,9 @@
|
|
|
331
335
|
[data-fs-contract-switcher-default] {
|
|
332
336
|
flex-shrink: 0;
|
|
333
337
|
color: var(--fs-color-link);
|
|
338
|
+
|
|
339
|
+
// See the drawer header star: the symbol carries no fill of its own.
|
|
340
|
+
fill: currentColor;
|
|
334
341
|
}
|
|
335
342
|
|
|
336
343
|
// Footer: Cancel / Confirm
|
|
@@ -13,21 +13,19 @@ export const OrganizationSignInButton = ({
|
|
|
13
13
|
}) => {
|
|
14
14
|
const [isOpen, setIsOpen] = useState(false)
|
|
15
15
|
|
|
16
|
-
const { person } = useSession()
|
|
16
|
+
const { person, b2b } = useSession()
|
|
17
17
|
const navigationLabels = useAccountNavigationLabels()
|
|
18
18
|
const companyLabel = navigationLabels?.companyLabel ?? 'Company'
|
|
19
19
|
const contractLabel = navigationLabels?.contractLabel ?? 'Contract'
|
|
20
|
+
const activeContractName = b2b?.contractName?.trim()
|
|
21
|
+
const label =
|
|
22
|
+
activeContractName || (person?.id ? companyLabel : contractLabel)
|
|
20
23
|
|
|
21
24
|
const openDrawer = (
|
|
22
25
|
event: MouseEvent<HTMLAnchorElement> | MouseEvent<HTMLButtonElement>
|
|
23
26
|
) => {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
if (true) {
|
|
27
|
-
event.preventDefault()
|
|
28
|
-
|
|
29
|
-
setIsOpen(true)
|
|
30
|
-
}
|
|
27
|
+
event.preventDefault()
|
|
28
|
+
setIsOpen(true)
|
|
31
29
|
}
|
|
32
30
|
|
|
33
31
|
const closeDrawer = () => {
|
|
@@ -45,7 +43,7 @@ export const OrganizationSignInButton = ({
|
|
|
45
43
|
icon={<Icon name={icon} width={18} height={18} weight="bold" />}
|
|
46
44
|
iconPosition="left"
|
|
47
45
|
>
|
|
48
|
-
{
|
|
46
|
+
{label}
|
|
49
47
|
</Button>
|
|
50
48
|
|
|
51
49
|
{isOpen && (
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { Button } from '@faststore/ui'
|
|
2
2
|
import { useRouter } from 'next/router'
|
|
3
|
+
import { useState } from 'react'
|
|
3
4
|
|
|
4
5
|
import Link from 'src/components/ui/Link'
|
|
5
6
|
import styles from '../section.module.scss'
|
|
6
7
|
|
|
8
|
+
import { OrganizationDrawer } from '../Drawer/OrganizationDrawer/OrganizationDrawer'
|
|
7
9
|
import { useAccountNavigationLabels } from 'src/sdk/account/accountPageContext'
|
|
10
|
+
import { useSession } from 'src/sdk/session'
|
|
8
11
|
import useScreenResize from 'src/sdk/ui/useScreenResize'
|
|
9
12
|
|
|
10
13
|
interface NavItem {
|
|
@@ -43,6 +46,9 @@ const Menu = ({ avatarImageUrl, accountName, items }: MenuProps) => {
|
|
|
43
46
|
const { isDesktop } = useScreenResize()
|
|
44
47
|
const navigationLabels = useAccountNavigationLabels()
|
|
45
48
|
const switchLabel = navigationLabels?.switchLabel ?? 'Switch'
|
|
49
|
+
const { b2b } = useSession()
|
|
50
|
+
const [isSwitcherOpen, setIsSwitcherOpen] = useState(false)
|
|
51
|
+
const canSwitchContract = Boolean(b2b?.unitId)
|
|
46
52
|
|
|
47
53
|
return (
|
|
48
54
|
<div className={styles.menu}>
|
|
@@ -56,16 +62,27 @@ const Menu = ({ avatarImageUrl, accountName, items }: MenuProps) => {
|
|
|
56
62
|
)}
|
|
57
63
|
<h2>{accountName}</h2>
|
|
58
64
|
</div>
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
65
|
+
{canSwitchContract && (
|
|
66
|
+
<Button
|
|
67
|
+
className={styles.switchButton}
|
|
68
|
+
variant="secondary"
|
|
69
|
+
size="small"
|
|
70
|
+
onClick={() => setIsSwitcherOpen(true)}
|
|
71
|
+
>
|
|
72
|
+
{switchLabel}
|
|
73
|
+
</Button>
|
|
74
|
+
)}
|
|
66
75
|
</div>
|
|
67
76
|
) : null}
|
|
68
77
|
<Nav items={items} />
|
|
78
|
+
{isSwitcherOpen && (
|
|
79
|
+
<OrganizationDrawer
|
|
80
|
+
isOpen
|
|
81
|
+
closeDrawer={() => setIsSwitcherOpen(false)}
|
|
82
|
+
isRepresentative={Boolean(b2b?.isRepresentative)}
|
|
83
|
+
initialView="switch"
|
|
84
|
+
/>
|
|
85
|
+
)}
|
|
69
86
|
</div>
|
|
70
87
|
)
|
|
71
88
|
}
|
|
@@ -3,8 +3,6 @@ import { useCallback, useRef, useState } from 'react'
|
|
|
3
3
|
|
|
4
4
|
import { Icon as UIIcon, useScrollDirection, useUI } from '@faststore/ui'
|
|
5
5
|
|
|
6
|
-
import { Skeleton as UISkeleton } from '@faststore/ui'
|
|
7
|
-
import { OrganizationSignInButton } from 'src/components/account/Drawer/OrganizationSignInButton'
|
|
8
6
|
import CartToggle from 'src/components/cart/CartToggle'
|
|
9
7
|
import type { SearchInputRef } from 'src/components/search/SearchInput'
|
|
10
8
|
import SearchInput from 'src/components/search/SearchInput'
|
|
@@ -13,10 +11,12 @@ import LocalizationButton from 'src/components/ui/LocalizationButton'
|
|
|
13
11
|
import Logo from 'src/components/ui/Logo'
|
|
14
12
|
import { useOverrideComponents } from 'src/sdk/overrides/OverrideContext'
|
|
15
13
|
import { useSession } from 'src/sdk/session'
|
|
14
|
+
import { isSignInAreaResolved } from 'src/sdk/session/isSignInAreaResolved'
|
|
16
15
|
import useScreenResize from 'src/sdk/ui/useScreenResize'
|
|
17
16
|
|
|
18
17
|
import storeConfig from 'discovery.config'
|
|
19
18
|
import type { NavbarProps as SectionNavbarProps } from '../../sections/Navbar'
|
|
19
|
+
import { NavbarAccountArea } from './NavbarAccountArea'
|
|
20
20
|
|
|
21
21
|
const NavbarLinks = dynamic(
|
|
22
22
|
() =>
|
|
@@ -121,7 +121,11 @@ function Navbar({
|
|
|
121
121
|
const scrollDirection = useScrollDirection()
|
|
122
122
|
const { openNavbar, navbar: displayNavbar } = useUI()
|
|
123
123
|
const { isDesktop } = useScreenResize()
|
|
124
|
-
const { b2b, isSessionReady } = useSession()
|
|
124
|
+
const { b2b, isSessionReady, hasValidated } = useSession()
|
|
125
|
+
const isSignInResolved = isSignInAreaResolved({
|
|
126
|
+
isSessionReady,
|
|
127
|
+
hasValidated,
|
|
128
|
+
})
|
|
125
129
|
|
|
126
130
|
const searchMobileRef = useRef<SearchInputRef>(null)
|
|
127
131
|
const [searchExpanded, setSearchExpanded] = useState(false)
|
|
@@ -232,22 +236,16 @@ function Navbar({
|
|
|
232
236
|
/>
|
|
233
237
|
)}
|
|
234
238
|
|
|
235
|
-
{isDesktop &&
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
) : (
|
|
246
|
-
<UISkeleton
|
|
247
|
-
data-fs-navbar-signin-skeleton
|
|
248
|
-
size={{ width: '140px', height: '2.5rem' }}
|
|
249
|
-
/>
|
|
250
|
-
))}
|
|
239
|
+
{isDesktop && (
|
|
240
|
+
<NavbarAccountArea
|
|
241
|
+
isSignInResolved={isSignInResolved}
|
|
242
|
+
isOrganizationEnabled={isOrganizationEnabled}
|
|
243
|
+
isRepresentative={isRepresentative}
|
|
244
|
+
signInButton={signInButton}
|
|
245
|
+
ButtonSignIn={ButtonSignIn}
|
|
246
|
+
skeletonAttr="data-fs-navbar-signin-skeleton"
|
|
247
|
+
/>
|
|
248
|
+
)}
|
|
251
249
|
|
|
252
250
|
<CartToggle {...cart} />
|
|
253
251
|
</NavbarButtons.Component>
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { Skeleton as UISkeleton } from '@faststore/ui'
|
|
2
|
+
|
|
3
|
+
import { OrganizationSignInButton } from 'src/components/account/Drawer/OrganizationSignInButton'
|
|
4
|
+
import type { useOverrideComponents } from 'src/sdk/overrides/OverrideContext'
|
|
5
|
+
|
|
6
|
+
import type { NavbarProps as SectionNavbarProps } from '../../sections/Navbar'
|
|
7
|
+
|
|
8
|
+
type NavbarOverrides = ReturnType<typeof useOverrideComponents<'Navbar'>>
|
|
9
|
+
|
|
10
|
+
export type NavbarAccountAreaProps = {
|
|
11
|
+
/** Whether the B2B session has resolved enough to know what to render. */
|
|
12
|
+
isSignInResolved: boolean
|
|
13
|
+
/** Whether the representative-only Organization sign-in area is enabled. */
|
|
14
|
+
isOrganizationEnabled: boolean | undefined
|
|
15
|
+
isRepresentative?: boolean
|
|
16
|
+
signInButton: SectionNavbarProps['signInButton']
|
|
17
|
+
ButtonSignIn: NavbarOverrides['_experimentalButtonSignIn']
|
|
18
|
+
/** Data attribute toggled on the loading skeleton (Navbar vs NavbarSlider). */
|
|
19
|
+
skeletonAttr:
|
|
20
|
+
| 'data-fs-navbar-signin-skeleton'
|
|
21
|
+
| 'data-fs-navbar-slider-signin-skeleton'
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Renders the account/sign-in area shared by the desktop Navbar and the
|
|
26
|
+
* mobile NavbarSlider: a loading skeleton until the B2B session resolves,
|
|
27
|
+
* then either the Organization sign-in button (representatives) or the
|
|
28
|
+
* storefront's own sign-in button.
|
|
29
|
+
*/
|
|
30
|
+
export const NavbarAccountArea = ({
|
|
31
|
+
isSignInResolved,
|
|
32
|
+
isOrganizationEnabled,
|
|
33
|
+
isRepresentative,
|
|
34
|
+
signInButton,
|
|
35
|
+
ButtonSignIn,
|
|
36
|
+
skeletonAttr,
|
|
37
|
+
}: NavbarAccountAreaProps) => {
|
|
38
|
+
if (!isSignInResolved) {
|
|
39
|
+
return (
|
|
40
|
+
<UISkeleton
|
|
41
|
+
{...{ [skeletonAttr]: true }}
|
|
42
|
+
size={{ width: '140px', height: '2.5rem' }}
|
|
43
|
+
/>
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (isOrganizationEnabled) {
|
|
48
|
+
return (
|
|
49
|
+
<OrganizationSignInButton
|
|
50
|
+
icon={signInButton.icon}
|
|
51
|
+
isRepresentative={isRepresentative}
|
|
52
|
+
/>
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return <ButtonSignIn.Component {...signInButton} />
|
|
57
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { useFadeEffect, useUI } from '@faststore/ui'
|
|
2
2
|
import { Suspense } from 'react'
|
|
3
3
|
|
|
4
4
|
import storeConfig from 'discovery.config'
|
|
@@ -9,10 +9,11 @@ import LocalizationButton from 'src/components/ui/LocalizationButton'
|
|
|
9
9
|
import Logo from 'src/components/ui/Logo'
|
|
10
10
|
|
|
11
11
|
import type { NavbarProps } from '../Navbar'
|
|
12
|
+
import { NavbarAccountArea } from '../Navbar/NavbarAccountArea'
|
|
12
13
|
|
|
13
|
-
import { OrganizationSignInButton } from 'src/components/account/Drawer/OrganizationSignInButton'
|
|
14
14
|
import { useOverrideComponents } from 'src/sdk/overrides/OverrideContext'
|
|
15
15
|
import { useSession } from 'src/sdk/session'
|
|
16
|
+
import { isSignInAreaResolved } from 'src/sdk/session/isSignInAreaResolved'
|
|
16
17
|
import styles from './section.module.scss'
|
|
17
18
|
|
|
18
19
|
interface NavbarSliderProps {
|
|
@@ -44,7 +45,11 @@ function NavbarSlider({
|
|
|
44
45
|
|
|
45
46
|
const { closeNavbar } = useUI()
|
|
46
47
|
const { fade, fadeOut } = useFadeEffect()
|
|
47
|
-
const { b2b, isSessionReady } = useSession()
|
|
48
|
+
const { b2b, isSessionReady, hasValidated } = useSession()
|
|
49
|
+
const isSignInResolved = isSignInAreaResolved({
|
|
50
|
+
isSessionReady,
|
|
51
|
+
hasValidated,
|
|
52
|
+
})
|
|
48
53
|
|
|
49
54
|
const isFaststoreMyAccountEnabled =
|
|
50
55
|
storeConfig.experimental?.enableFaststoreMyAccount
|
|
@@ -84,21 +89,14 @@ function NavbarSlider({
|
|
|
84
89
|
</NavbarSliderContent.Component>
|
|
85
90
|
<NavbarSliderFooter.Component {...NavbarSliderFooter.props}>
|
|
86
91
|
<Suspense fallback={<ButtonSignInFallback />}>
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
)
|
|
96
|
-
) : (
|
|
97
|
-
<UISkeleton
|
|
98
|
-
data-fs-navbar-slider-signin-skeleton
|
|
99
|
-
size={{ width: '140px', height: '2.5rem' }}
|
|
100
|
-
/>
|
|
101
|
-
)}
|
|
92
|
+
<NavbarAccountArea
|
|
93
|
+
isSignInResolved={isSignInResolved}
|
|
94
|
+
isOrganizationEnabled={isOrganizationEnabled}
|
|
95
|
+
isRepresentative={isRepresentative}
|
|
96
|
+
signInButton={signInButton}
|
|
97
|
+
ButtonSignIn={ButtonSignIn}
|
|
98
|
+
skeletonAttr="data-fs-navbar-slider-signin-skeleton"
|
|
99
|
+
/>
|
|
102
100
|
</Suspense>
|
|
103
101
|
{isLocalizationEnabled && (
|
|
104
102
|
<LocalizationButton
|
|
@@ -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
|