@salesforce/pwa-kit-create-app 3.9.0-preview.2 → 3.9.0-preview.4
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/assets/bootstrap/js/config/default.js.hbs +6 -2
- package/assets/bootstrap/js/overrides/app/components/_app-config/index.jsx.hbs +2 -0
- package/assets/bootstrap/js/overrides/app/ssr.js.hbs +199 -16
- package/assets/templates/@salesforce/retail-react-app/app/components/_app-config/index.jsx.hbs +2 -0
- package/assets/templates/@salesforce/retail-react-app/app/ssr.js.hbs +199 -16
- package/assets/templates/@salesforce/retail-react-app/config/default.js.hbs +7 -3
- package/package.json +4 -4
- package/templates/express-minimal.tar.gz +0 -0
- package/templates/mrt-reference-app.tar.gz +0 -0
- package/templates/retail-react-app.tar.gz +0 -0
- package/templates/typescript-minimal.tar.gz +0 -0
|
@@ -25,7 +25,9 @@ module.exports = {
|
|
|
25
25
|
// Required in 'callback' mode; if missing, passwordless login defaults to 'sms' mode, which requires Marketing Cloud configuration.
|
|
26
26
|
// If the env var `PASSWORDLESS_LOGIN_CALLBACK_URI` is set, it will override the config value.
|
|
27
27
|
callbackURI:
|
|
28
|
-
process.env.PASSWORDLESS_LOGIN_CALLBACK_URI || '/passwordless-login-callback'
|
|
28
|
+
process.env.PASSWORDLESS_LOGIN_CALLBACK_URI || '/passwordless-login-callback',
|
|
29
|
+
// The landing path for passwordless login
|
|
30
|
+
landingPath: '/passwordless-login-landing'
|
|
29
31
|
},
|
|
30
32
|
social: {
|
|
31
33
|
// Enables or disables social login for the site. Defaults to: false
|
|
@@ -41,7 +43,9 @@ module.exports = {
|
|
|
41
43
|
resetPassword: {
|
|
42
44
|
// The callback URI, which can be an absolute URL (including third-party URIs) or a relative path set up by the developer.
|
|
43
45
|
// If the env var `RESET_PASSWORD_CALLBACK_URI` is set, it will override the config value.
|
|
44
|
-
callbackURI: process.env.RESET_PASSWORD_CALLBACK_URI || '/reset-password-callback'
|
|
46
|
+
callbackURI: process.env.RESET_PASSWORD_CALLBACK_URI || '/reset-password-callback',
|
|
47
|
+
// The landing path for reset password
|
|
48
|
+
landingPath: '/reset-password-landing'
|
|
45
49
|
}
|
|
46
50
|
},
|
|
47
51
|
// The default site for your app. This value will be used when a siteRef could not be determined from the url
|
|
@@ -27,6 +27,7 @@ import {withReactQuery} from '@salesforce/pwa-kit-react-sdk/ssr/universal/compon
|
|
|
27
27
|
import {useCorrelationId} from '@salesforce/pwa-kit-react-sdk/ssr/universal/hooks'
|
|
28
28
|
import {getAppOrigin} from '@salesforce/pwa-kit-react-sdk/utils/url'
|
|
29
29
|
import {ReactQueryDevtools} from '@tanstack/react-query-devtools'
|
|
30
|
+
import {DEFAULT_DNT_STATE} from '@salesforce/retail-react-app/app/constants'
|
|
30
31
|
|
|
31
32
|
/**
|
|
32
33
|
* Use the AppConfig component to inject extra arguments into the getProps
|
|
@@ -57,6 +58,7 @@ const AppConfig = ({children, locals = {}}) => {
|
|
|
57
58
|
redirectURI={`${appOrigin}/callback`}
|
|
58
59
|
proxy={`${appOrigin}${commerceApiConfig.proxyPath}`}
|
|
59
60
|
headers={headers}
|
|
61
|
+
defaultDnt={DEFAULT_DNT_STATE}
|
|
60
62
|
logger={createLogger({packageName: 'commerce-sdk-react'})}
|
|
61
63
|
{{#if answers.project.commerce.isSlasPrivate}}
|
|
62
64
|
// Set 'enablePWAKitPrivateClient' to true use SLAS private client login flows.
|
|
@@ -7,22 +7,15 @@
|
|
|
7
7
|
|
|
8
8
|
'use strict'
|
|
9
9
|
|
|
10
|
+
import crypto from 'crypto'
|
|
11
|
+
import express from 'express'
|
|
12
|
+
import helmet from 'helmet'
|
|
13
|
+
import {createRemoteJWKSet as joseCreateRemoteJWKSet, jwtVerify, decodeJwt} from 'jose'
|
|
10
14
|
import path from 'path'
|
|
11
15
|
import {getRuntime} from '@salesforce/pwa-kit-runtime/ssr/server/express'
|
|
12
16
|
import {defaultPwaKitSecurityHeaders} from '@salesforce/pwa-kit-runtime/utils/middleware'
|
|
13
17
|
import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config'
|
|
14
|
-
import
|
|
15
|
-
|
|
16
|
-
import express from 'express'
|
|
17
|
-
import {emailLink} from '@salesforce/retail-react-app/app/utils/marketing-cloud/marketing-cloud-email-link'
|
|
18
|
-
import {
|
|
19
|
-
PASSWORDLESS_LOGIN_LANDING_PATH,
|
|
20
|
-
RESET_PASSWORD_LANDING_PATH
|
|
21
|
-
} from '@salesforce/retail-react-app/app/constants'
|
|
22
|
-
import {
|
|
23
|
-
validateSlasCallbackToken,
|
|
24
|
-
jwksCaching
|
|
25
|
-
} from '@salesforce/retail-react-app/app/utils/jwt-utils'
|
|
18
|
+
import {getAppOrigin} from '@salesforce/pwa-kit-react-sdk/utils/url'
|
|
26
19
|
|
|
27
20
|
const config = getConfig()
|
|
28
21
|
|
|
@@ -62,6 +55,117 @@ const options = {
|
|
|
62
55
|
|
|
63
56
|
const runtime = getRuntime()
|
|
64
57
|
|
|
58
|
+
/**
|
|
59
|
+
* Tokens are valid for 20 minutes. We store it at the top level scope to reuse
|
|
60
|
+
* it during the lambda invocation. We'll refresh it after 15 minutes.
|
|
61
|
+
*/
|
|
62
|
+
let marketingCloudToken = ''
|
|
63
|
+
let marketingCloudTokenExpiration = new Date()
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Generates a unique ID for the email message.
|
|
67
|
+
*
|
|
68
|
+
* @return {string} A unique ID for the email message.
|
|
69
|
+
*/
|
|
70
|
+
function generateUniqueId() {
|
|
71
|
+
return crypto.randomBytes(16).toString('hex')
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Sends an email to a specified contact using the Marketing Cloud API. The template email must have a
|
|
76
|
+
* `%%magic-link%%` personalization string inserted.
|
|
77
|
+
* https://help.salesforce.com/s/articleView?id=mktg.mc_es_personalization_strings.htm&type=5
|
|
78
|
+
*
|
|
79
|
+
* @param {string} email - The email address of the contact to whom the email will be sent.
|
|
80
|
+
* @param {string} templateId - The ID of the email template to be used for the email.
|
|
81
|
+
* @param {string} magicLink - The magic link to be included in the email.
|
|
82
|
+
*
|
|
83
|
+
* @return {Promise<object>} A promise that resolves to the response object received from the Marketing Cloud API.
|
|
84
|
+
*/
|
|
85
|
+
async function sendMarketingCloudEmail(emailId, marketingCloudConfig) {
|
|
86
|
+
// Refresh token if expired
|
|
87
|
+
if (new Date() > marketingCloudTokenExpiration) {
|
|
88
|
+
const {clientId, clientSecret, subdomain} = marketingCloudConfig
|
|
89
|
+
const tokenUrl = `https://${subdomain}.auth.marketingcloudapis.com/v2/token`
|
|
90
|
+
const tokenResponse = await fetch(tokenUrl, {
|
|
91
|
+
method: 'POST',
|
|
92
|
+
headers: {'Content-Type': 'application/json'},
|
|
93
|
+
body: JSON.stringify({
|
|
94
|
+
grant_type: 'client_credentials',
|
|
95
|
+
client_id: clientId,
|
|
96
|
+
client_secret: clientSecret
|
|
97
|
+
})
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
if (!tokenResponse.ok)
|
|
101
|
+
throw new Error(
|
|
102
|
+
'Failed to fetch Marketing Cloud access token. Check your Marketing Cloud credentials and try again.'
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
const {access_token} = await tokenResponse.json()
|
|
106
|
+
marketingCloudToken = access_token
|
|
107
|
+
// Set expiration to 15 mins
|
|
108
|
+
marketingCloudTokenExpiration = new Date(Date.now() + 15 * 60 * 1000)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Send the email
|
|
112
|
+
const emailUrl = `https://${
|
|
113
|
+
marketingCloudConfig.subdomain
|
|
114
|
+
}.rest.marketingcloudapis.com/messaging/v1/email/messages/${generateUniqueId()}`
|
|
115
|
+
const emailResponse = await fetch(emailUrl, {
|
|
116
|
+
method: 'POST',
|
|
117
|
+
headers: {
|
|
118
|
+
Authorization: `Bearer ${marketingCloudToken}`,
|
|
119
|
+
'Content-Type': 'application/json'
|
|
120
|
+
},
|
|
121
|
+
body: JSON.stringify({
|
|
122
|
+
definitionKey: marketingCloudConfig.templateId,
|
|
123
|
+
recipient: {
|
|
124
|
+
contactKey: emailId,
|
|
125
|
+
to: emailId,
|
|
126
|
+
attributes: {'magic-link': marketingCloudConfig.magicLink}
|
|
127
|
+
}
|
|
128
|
+
})
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
if (!emailResponse.ok) throw new Error('Failed to send email to Marketing Cloud')
|
|
132
|
+
|
|
133
|
+
return await emailResponse.json()
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Generates a unique ID, constructs an email message URL, and sends the email to the specified contact
|
|
138
|
+
* using the Marketing Cloud API.
|
|
139
|
+
*
|
|
140
|
+
* @param {string} email - The email address of the contact to whom the email will be sent.
|
|
141
|
+
* @param {string} templateId - The ID of the email template to be used for the email.
|
|
142
|
+
* @param {string} magicLink - The magic link to be included in the email.
|
|
143
|
+
*
|
|
144
|
+
* @return {Promise<object>} A promise that resolves to the response object received from the Marketing Cloud API.
|
|
145
|
+
*/
|
|
146
|
+
export async function emailLink(emailId, templateId, magicLink) {
|
|
147
|
+
if (!process.env.MARKETING_CLOUD_CLIENT_ID) {
|
|
148
|
+
console.warn('MARKETING_CLOUD_CLIENT_ID is not set in the environment variables.')
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (!process.env.MARKETING_CLOUD_CLIENT_SECRET) {
|
|
152
|
+
console.warn(' MARKETING_CLOUD_CLIENT_SECRET is not set in the environment variables.')
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (!process.env.MARKETING_CLOUD_SUBDOMAIN) {
|
|
156
|
+
console.warn('MARKETING_CLOUD_SUBDOMAIN is not set in the environment variables.')
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const marketingCloudConfig = {
|
|
160
|
+
clientId: process.env.MARKETING_CLOUD_CLIENT_ID,
|
|
161
|
+
clientSecret: process.env.MARKETING_CLOUD_CLIENT_SECRET,
|
|
162
|
+
magicLink: magicLink,
|
|
163
|
+
subdomain: process.env.MARKETING_CLOUD_SUBDOMAIN,
|
|
164
|
+
templateId: templateId
|
|
165
|
+
}
|
|
166
|
+
return await sendMarketingCloudEmail(emailId, marketingCloudConfig)
|
|
167
|
+
}
|
|
168
|
+
|
|
65
169
|
const resetPasswordCallback =
|
|
66
170
|
config.app.login?.resetPassword?.callbackURI || '/reset-password-callback'
|
|
67
171
|
const passwordlessLoginCallback =
|
|
@@ -78,11 +182,11 @@ async function sendMagicLinkEmail(req, res, landingPath, emailTemplate, redirect
|
|
|
78
182
|
|
|
79
183
|
// Construct the magic link URL
|
|
80
184
|
let magicLink = `${base}${landingPath}?token=${encodeURIComponent(token)}`
|
|
81
|
-
if (landingPath ===
|
|
185
|
+
if (landingPath === config.app.login?.resetPassword?.landingPath) {
|
|
82
186
|
// Add email query parameter for reset password flow
|
|
83
187
|
magicLink += `&email=${encodeURIComponent(email_id)}`
|
|
84
188
|
}
|
|
85
|
-
if (landingPath ===
|
|
189
|
+
if (landingPath === config.app.login?.passwordless?.landingPath && redirectUrl) {
|
|
86
190
|
magicLink += `&redirect_url=${encodeURIComponent(redirectUrl)}`
|
|
87
191
|
}
|
|
88
192
|
|
|
@@ -93,6 +197,85 @@ async function sendMagicLinkEmail(req, res, landingPath, emailTemplate, redirect
|
|
|
93
197
|
res.send(emailLinkResponse)
|
|
94
198
|
}
|
|
95
199
|
|
|
200
|
+
const CLAIM = {
|
|
201
|
+
ISSUER: 'iss'
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const DELIMITER = {
|
|
205
|
+
ISSUER: '/'
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const throwSlasTokenValidationError = (message, code) => {
|
|
209
|
+
throw new Error(`SLAS Token Validation Error: ${message}`, code)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export const createRemoteJWKSet = (tenantId) => {
|
|
213
|
+
const appOrigin = getAppOrigin()
|
|
214
|
+
const {app: appConfig} = getConfig()
|
|
215
|
+
const shortCode = appConfig.commerceAPI.parameters.shortCode
|
|
216
|
+
const configTenantId = appConfig.commerceAPI.parameters.organizationId.replace(/^f_ecom_/, '')
|
|
217
|
+
if (tenantId !== configTenantId) {
|
|
218
|
+
throw new Error(
|
|
219
|
+
`The tenant ID in your PWA Kit configuration ("${configTenantId}") does not match the tenant ID in the SLAS callback token ("${tenantId}").`
|
|
220
|
+
)
|
|
221
|
+
}
|
|
222
|
+
const JWKS_URI = `${appOrigin}/${shortCode}/${tenantId}/oauth2/jwks`
|
|
223
|
+
return joseCreateRemoteJWKSet(new URL(JWKS_URI))
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export const validateSlasCallbackToken = async (token) => {
|
|
227
|
+
const payload = decodeJwt(token)
|
|
228
|
+
const subClaim = payload[CLAIM.ISSUER]
|
|
229
|
+
const tokens = subClaim.split(DELIMITER.ISSUER)
|
|
230
|
+
const tenantId = tokens[2]
|
|
231
|
+
try {
|
|
232
|
+
const jwks = createRemoteJWKSet(tenantId)
|
|
233
|
+
const {payload} = await jwtVerify(token, jwks, {})
|
|
234
|
+
return payload
|
|
235
|
+
} catch (error) {
|
|
236
|
+
throwSlasTokenValidationError(error.message, 401)
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const tenantIdRegExp = /^[a-zA-Z]{4}_([0-9]{3}|s[0-9]{2}|stg|dev|prd)$/
|
|
241
|
+
const shortCodeRegExp = /^[a-zA-Z0-9-]+$/
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Handles JWKS (JSON Web Key Set) caching the JWKS response for 2 weeks.
|
|
245
|
+
*
|
|
246
|
+
* @param {object} req Express request object.
|
|
247
|
+
* @param {object} res Express response object.
|
|
248
|
+
* @param {object} options Options for fetching B2C Commerce API JWKS.
|
|
249
|
+
* @param {string} options.shortCode - The Short Code assigned to the realm.
|
|
250
|
+
* @param {string} options.tenantId - The Tenant ID for the ECOM instance.
|
|
251
|
+
* @returns {Promise<*>} Promise with the JWKS data.
|
|
252
|
+
*/
|
|
253
|
+
export async function jwksCaching(req, res, options) {
|
|
254
|
+
const {shortCode, tenantId} = options
|
|
255
|
+
|
|
256
|
+
const isValidRequest = tenantIdRegExp.test(tenantId) && shortCodeRegExp.test(shortCode)
|
|
257
|
+
if (!isValidRequest)
|
|
258
|
+
return res
|
|
259
|
+
.status(400)
|
|
260
|
+
.json({error: 'Bad request parameters: Tenant ID or short code is invalid.'})
|
|
261
|
+
try {
|
|
262
|
+
const JWKS_URI = `https://${shortCode}.api.commercecloud.salesforce.com/shopper/auth/v1/organizations/f_ecom_${tenantId}/oauth2/jwks`
|
|
263
|
+
const response = await fetch(JWKS_URI)
|
|
264
|
+
|
|
265
|
+
if (!response.ok) {
|
|
266
|
+
throw new Error('Request failed with status: ' + response.status)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// JWKS rotate every 30 days. For now, cache response for 14 days so that
|
|
270
|
+
// fetches only need to happen twice a month
|
|
271
|
+
res.set('Cache-Control', 'public, max-age=1209600, stale-while-revalidate=86400')
|
|
272
|
+
|
|
273
|
+
return res.json(await response.json())
|
|
274
|
+
} catch (error) {
|
|
275
|
+
res.status(400).json({error: `Error while fetching data: ${error.message}`})
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
96
279
|
const {handler} = runtime.createHandler(options, (app) => {
|
|
97
280
|
app.use(express.json()) // To parse JSON payloads
|
|
98
281
|
app.use(express.urlencoded({extended: true}))
|
|
@@ -144,7 +327,7 @@ const {handler} = runtime.createHandler(options, (app) => {
|
|
|
144
327
|
sendMagicLinkEmail(
|
|
145
328
|
req,
|
|
146
329
|
res,
|
|
147
|
-
|
|
330
|
+
config.app.login?.passwordless?.landingPath,
|
|
148
331
|
process.env.MARKETING_CLOUD_PASSWORDLESS_LOGIN_TEMPLATE,
|
|
149
332
|
redirectUrl
|
|
150
333
|
)
|
|
@@ -161,7 +344,7 @@ const {handler} = runtime.createHandler(options, (app) => {
|
|
|
161
344
|
sendMagicLinkEmail(
|
|
162
345
|
req,
|
|
163
346
|
res,
|
|
164
|
-
|
|
347
|
+
config.app.login?.resetPassword?.landingPath,
|
|
165
348
|
process.env.MARKETING_CLOUD_RESET_PASSWORD_TEMPLATE
|
|
166
349
|
)
|
|
167
350
|
})
|
package/assets/templates/@salesforce/retail-react-app/app/components/_app-config/index.jsx.hbs
CHANGED
|
@@ -27,6 +27,7 @@ import {CommerceApiProvider} from '@salesforce/commerce-sdk-react'
|
|
|
27
27
|
import {withReactQuery} from '@salesforce/pwa-kit-react-sdk/ssr/universal/components/with-react-query'
|
|
28
28
|
import {useCorrelationId} from '@salesforce/pwa-kit-react-sdk/ssr/universal/hooks'
|
|
29
29
|
import {ReactQueryDevtools} from '@tanstack/react-query-devtools'
|
|
30
|
+
import {DEFAULT_DNT_STATE} from '@salesforce/retail-react-app/app/constants'
|
|
30
31
|
|
|
31
32
|
/**
|
|
32
33
|
* Use the AppConfig component to inject extra arguments into the getProps
|
|
@@ -57,6 +58,7 @@ const AppConfig = ({children, locals = {}}) => {
|
|
|
57
58
|
redirectURI={`${appOrigin}/callback`}
|
|
58
59
|
proxy={`${appOrigin}${commerceApiConfig.proxyPath}`}
|
|
59
60
|
headers={headers}
|
|
61
|
+
defaultDnt={DEFAULT_DNT_STATE}
|
|
60
62
|
logger={createLogger({packageName: 'commerce-sdk-react'})}
|
|
61
63
|
{{#if answers.project.commerce.isSlasPrivate}}
|
|
62
64
|
// Set 'enablePWAKitPrivateClient' to true use SLAS private client login flows.
|
|
@@ -7,22 +7,15 @@
|
|
|
7
7
|
|
|
8
8
|
'use strict'
|
|
9
9
|
|
|
10
|
+
import crypto from 'crypto'
|
|
11
|
+
import express from 'express'
|
|
12
|
+
import helmet from 'helmet'
|
|
13
|
+
import {createRemoteJWKSet as joseCreateRemoteJWKSet, jwtVerify, decodeJwt} from 'jose'
|
|
10
14
|
import path from 'path'
|
|
11
15
|
import {getRuntime} from '@salesforce/pwa-kit-runtime/ssr/server/express'
|
|
12
16
|
import {defaultPwaKitSecurityHeaders} from '@salesforce/pwa-kit-runtime/utils/middleware'
|
|
13
17
|
import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config'
|
|
14
|
-
import
|
|
15
|
-
|
|
16
|
-
import express from 'express'
|
|
17
|
-
import {emailLink} from '@salesforce/retail-react-app/app/utils/marketing-cloud/marketing-cloud-email-link'
|
|
18
|
-
import {
|
|
19
|
-
PASSWORDLESS_LOGIN_LANDING_PATH,
|
|
20
|
-
RESET_PASSWORD_LANDING_PATH
|
|
21
|
-
} from '@salesforce/retail-react-app/app/constants'
|
|
22
|
-
import {
|
|
23
|
-
validateSlasCallbackToken,
|
|
24
|
-
jwksCaching
|
|
25
|
-
} from '@salesforce/retail-react-app/app/utils/jwt-utils'
|
|
18
|
+
import {getAppOrigin} from '@salesforce/pwa-kit-react-sdk/utils/url'
|
|
26
19
|
|
|
27
20
|
const config = getConfig()
|
|
28
21
|
|
|
@@ -62,6 +55,117 @@ const options = {
|
|
|
62
55
|
|
|
63
56
|
const runtime = getRuntime()
|
|
64
57
|
|
|
58
|
+
/**
|
|
59
|
+
* Tokens are valid for 20 minutes. We store it at the top level scope to reuse
|
|
60
|
+
* it during the lambda invocation. We'll refresh it after 15 minutes.
|
|
61
|
+
*/
|
|
62
|
+
let marketingCloudToken = ''
|
|
63
|
+
let marketingCloudTokenExpiration = new Date()
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Generates a unique ID for the email message.
|
|
67
|
+
*
|
|
68
|
+
* @return {string} A unique ID for the email message.
|
|
69
|
+
*/
|
|
70
|
+
function generateUniqueId() {
|
|
71
|
+
return crypto.randomBytes(16).toString('hex')
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Sends an email to a specified contact using the Marketing Cloud API. The template email must have a
|
|
76
|
+
* `%%magic-link%%` personalization string inserted.
|
|
77
|
+
* https://help.salesforce.com/s/articleView?id=mktg.mc_es_personalization_strings.htm&type=5
|
|
78
|
+
*
|
|
79
|
+
* @param {string} email - The email address of the contact to whom the email will be sent.
|
|
80
|
+
* @param {string} templateId - The ID of the email template to be used for the email.
|
|
81
|
+
* @param {string} magicLink - The magic link to be included in the email.
|
|
82
|
+
*
|
|
83
|
+
* @return {Promise<object>} A promise that resolves to the response object received from the Marketing Cloud API.
|
|
84
|
+
*/
|
|
85
|
+
async function sendMarketingCloudEmail(emailId, marketingCloudConfig) {
|
|
86
|
+
// Refresh token if expired
|
|
87
|
+
if (new Date() > marketingCloudTokenExpiration) {
|
|
88
|
+
const {clientId, clientSecret, subdomain} = marketingCloudConfig
|
|
89
|
+
const tokenUrl = `https://${subdomain}.auth.marketingcloudapis.com/v2/token`
|
|
90
|
+
const tokenResponse = await fetch(tokenUrl, {
|
|
91
|
+
method: 'POST',
|
|
92
|
+
headers: {'Content-Type': 'application/json'},
|
|
93
|
+
body: JSON.stringify({
|
|
94
|
+
grant_type: 'client_credentials',
|
|
95
|
+
client_id: clientId,
|
|
96
|
+
client_secret: clientSecret
|
|
97
|
+
})
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
if (!tokenResponse.ok)
|
|
101
|
+
throw new Error(
|
|
102
|
+
'Failed to fetch Marketing Cloud access token. Check your Marketing Cloud credentials and try again.'
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
const {access_token} = await tokenResponse.json()
|
|
106
|
+
marketingCloudToken = access_token
|
|
107
|
+
// Set expiration to 15 mins
|
|
108
|
+
marketingCloudTokenExpiration = new Date(Date.now() + 15 * 60 * 1000)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Send the email
|
|
112
|
+
const emailUrl = `https://${
|
|
113
|
+
marketingCloudConfig.subdomain
|
|
114
|
+
}.rest.marketingcloudapis.com/messaging/v1/email/messages/${generateUniqueId()}`
|
|
115
|
+
const emailResponse = await fetch(emailUrl, {
|
|
116
|
+
method: 'POST',
|
|
117
|
+
headers: {
|
|
118
|
+
Authorization: `Bearer ${marketingCloudToken}`,
|
|
119
|
+
'Content-Type': 'application/json'
|
|
120
|
+
},
|
|
121
|
+
body: JSON.stringify({
|
|
122
|
+
definitionKey: marketingCloudConfig.templateId,
|
|
123
|
+
recipient: {
|
|
124
|
+
contactKey: emailId,
|
|
125
|
+
to: emailId,
|
|
126
|
+
attributes: {'magic-link': marketingCloudConfig.magicLink}
|
|
127
|
+
}
|
|
128
|
+
})
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
if (!emailResponse.ok) throw new Error('Failed to send email to Marketing Cloud')
|
|
132
|
+
|
|
133
|
+
return await emailResponse.json()
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Generates a unique ID, constructs an email message URL, and sends the email to the specified contact
|
|
138
|
+
* using the Marketing Cloud API.
|
|
139
|
+
*
|
|
140
|
+
* @param {string} email - The email address of the contact to whom the email will be sent.
|
|
141
|
+
* @param {string} templateId - The ID of the email template to be used for the email.
|
|
142
|
+
* @param {string} magicLink - The magic link to be included in the email.
|
|
143
|
+
*
|
|
144
|
+
* @return {Promise<object>} A promise that resolves to the response object received from the Marketing Cloud API.
|
|
145
|
+
*/
|
|
146
|
+
export async function emailLink(emailId, templateId, magicLink) {
|
|
147
|
+
if (!process.env.MARKETING_CLOUD_CLIENT_ID) {
|
|
148
|
+
console.warn('MARKETING_CLOUD_CLIENT_ID is not set in the environment variables.')
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (!process.env.MARKETING_CLOUD_CLIENT_SECRET) {
|
|
152
|
+
console.warn(' MARKETING_CLOUD_CLIENT_SECRET is not set in the environment variables.')
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (!process.env.MARKETING_CLOUD_SUBDOMAIN) {
|
|
156
|
+
console.warn('MARKETING_CLOUD_SUBDOMAIN is not set in the environment variables.')
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const marketingCloudConfig = {
|
|
160
|
+
clientId: process.env.MARKETING_CLOUD_CLIENT_ID,
|
|
161
|
+
clientSecret: process.env.MARKETING_CLOUD_CLIENT_SECRET,
|
|
162
|
+
magicLink: magicLink,
|
|
163
|
+
subdomain: process.env.MARKETING_CLOUD_SUBDOMAIN,
|
|
164
|
+
templateId: templateId
|
|
165
|
+
}
|
|
166
|
+
return await sendMarketingCloudEmail(emailId, marketingCloudConfig)
|
|
167
|
+
}
|
|
168
|
+
|
|
65
169
|
const resetPasswordCallback =
|
|
66
170
|
config.app.login?.resetPassword?.callbackURI || '/reset-password-callback'
|
|
67
171
|
const passwordlessLoginCallback =
|
|
@@ -78,11 +182,11 @@ async function sendMagicLinkEmail(req, res, landingPath, emailTemplate, redirect
|
|
|
78
182
|
|
|
79
183
|
// Construct the magic link URL
|
|
80
184
|
let magicLink = `${base}${landingPath}?token=${encodeURIComponent(token)}`
|
|
81
|
-
if (landingPath ===
|
|
185
|
+
if (landingPath === config.app.login?.resetPassword?.landingPath) {
|
|
82
186
|
// Add email query parameter for reset password flow
|
|
83
187
|
magicLink += `&email=${encodeURIComponent(email_id)}`
|
|
84
188
|
}
|
|
85
|
-
if (landingPath ===
|
|
189
|
+
if (landingPath === config.app.login?.passwordless?.landingPath && redirectUrl) {
|
|
86
190
|
magicLink += `&redirect_url=${encodeURIComponent(redirectUrl)}`
|
|
87
191
|
}
|
|
88
192
|
|
|
@@ -93,6 +197,85 @@ async function sendMagicLinkEmail(req, res, landingPath, emailTemplate, redirect
|
|
|
93
197
|
res.send(emailLinkResponse)
|
|
94
198
|
}
|
|
95
199
|
|
|
200
|
+
const CLAIM = {
|
|
201
|
+
ISSUER: 'iss'
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const DELIMITER = {
|
|
205
|
+
ISSUER: '/'
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const throwSlasTokenValidationError = (message, code) => {
|
|
209
|
+
throw new Error(`SLAS Token Validation Error: ${message}`, code)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export const createRemoteJWKSet = (tenantId) => {
|
|
213
|
+
const appOrigin = getAppOrigin()
|
|
214
|
+
const {app: appConfig} = getConfig()
|
|
215
|
+
const shortCode = appConfig.commerceAPI.parameters.shortCode
|
|
216
|
+
const configTenantId = appConfig.commerceAPI.parameters.organizationId.replace(/^f_ecom_/, '')
|
|
217
|
+
if (tenantId !== configTenantId) {
|
|
218
|
+
throw new Error(
|
|
219
|
+
`The tenant ID in your PWA Kit configuration ("${configTenantId}") does not match the tenant ID in the SLAS callback token ("${tenantId}").`
|
|
220
|
+
)
|
|
221
|
+
}
|
|
222
|
+
const JWKS_URI = `${appOrigin}/${shortCode}/${tenantId}/oauth2/jwks`
|
|
223
|
+
return joseCreateRemoteJWKSet(new URL(JWKS_URI))
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export const validateSlasCallbackToken = async (token) => {
|
|
227
|
+
const payload = decodeJwt(token)
|
|
228
|
+
const subClaim = payload[CLAIM.ISSUER]
|
|
229
|
+
const tokens = subClaim.split(DELIMITER.ISSUER)
|
|
230
|
+
const tenantId = tokens[2]
|
|
231
|
+
try {
|
|
232
|
+
const jwks = createRemoteJWKSet(tenantId)
|
|
233
|
+
const {payload} = await jwtVerify(token, jwks, {})
|
|
234
|
+
return payload
|
|
235
|
+
} catch (error) {
|
|
236
|
+
throwSlasTokenValidationError(error.message, 401)
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const tenantIdRegExp = /^[a-zA-Z]{4}_([0-9]{3}|s[0-9]{2}|stg|dev|prd)$/
|
|
241
|
+
const shortCodeRegExp = /^[a-zA-Z0-9-]+$/
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Handles JWKS (JSON Web Key Set) caching the JWKS response for 2 weeks.
|
|
245
|
+
*
|
|
246
|
+
* @param {object} req Express request object.
|
|
247
|
+
* @param {object} res Express response object.
|
|
248
|
+
* @param {object} options Options for fetching B2C Commerce API JWKS.
|
|
249
|
+
* @param {string} options.shortCode - The Short Code assigned to the realm.
|
|
250
|
+
* @param {string} options.tenantId - The Tenant ID for the ECOM instance.
|
|
251
|
+
* @returns {Promise<*>} Promise with the JWKS data.
|
|
252
|
+
*/
|
|
253
|
+
export async function jwksCaching(req, res, options) {
|
|
254
|
+
const {shortCode, tenantId} = options
|
|
255
|
+
|
|
256
|
+
const isValidRequest = tenantIdRegExp.test(tenantId) && shortCodeRegExp.test(shortCode)
|
|
257
|
+
if (!isValidRequest)
|
|
258
|
+
return res
|
|
259
|
+
.status(400)
|
|
260
|
+
.json({error: 'Bad request parameters: Tenant ID or short code is invalid.'})
|
|
261
|
+
try {
|
|
262
|
+
const JWKS_URI = `https://${shortCode}.api.commercecloud.salesforce.com/shopper/auth/v1/organizations/f_ecom_${tenantId}/oauth2/jwks`
|
|
263
|
+
const response = await fetch(JWKS_URI)
|
|
264
|
+
|
|
265
|
+
if (!response.ok) {
|
|
266
|
+
throw new Error('Request failed with status: ' + response.status)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// JWKS rotate every 30 days. For now, cache response for 14 days so that
|
|
270
|
+
// fetches only need to happen twice a month
|
|
271
|
+
res.set('Cache-Control', 'public, max-age=1209600, stale-while-revalidate=86400')
|
|
272
|
+
|
|
273
|
+
return res.json(await response.json())
|
|
274
|
+
} catch (error) {
|
|
275
|
+
res.status(400).json({error: `Error while fetching data: ${error.message}`})
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
96
279
|
const {handler} = runtime.createHandler(options, (app) => {
|
|
97
280
|
app.use(express.json()) // To parse JSON payloads
|
|
98
281
|
app.use(express.urlencoded({extended: true}))
|
|
@@ -144,7 +327,7 @@ const {handler} = runtime.createHandler(options, (app) => {
|
|
|
144
327
|
sendMagicLinkEmail(
|
|
145
328
|
req,
|
|
146
329
|
res,
|
|
147
|
-
|
|
330
|
+
config.app.login?.passwordless?.landingPath,
|
|
148
331
|
process.env.MARKETING_CLOUD_PASSWORDLESS_LOGIN_TEMPLATE,
|
|
149
332
|
redirectUrl
|
|
150
333
|
)
|
|
@@ -161,7 +344,7 @@ const {handler} = runtime.createHandler(options, (app) => {
|
|
|
161
344
|
sendMagicLinkEmail(
|
|
162
345
|
req,
|
|
163
346
|
res,
|
|
164
|
-
|
|
347
|
+
config.app.login?.resetPassword?.landingPath,
|
|
165
348
|
process.env.MARKETING_CLOUD_RESET_PASSWORD_TEMPLATE
|
|
166
349
|
)
|
|
167
350
|
})
|
|
@@ -26,8 +26,10 @@ module.exports = {
|
|
|
26
26
|
// The callback URI, which can be an absolute URL (including third-party URIs) or a relative path set up by the developer.
|
|
27
27
|
// Required in 'callback' mode; if missing, passwordless login defaults to 'sms' mode, which requires Marketing Cloud configuration.
|
|
28
28
|
// If the env var `PASSWORDLESS_LOGIN_CALLBACK_URI` is set, it will override the config value.
|
|
29
|
-
callbackURI:
|
|
30
|
-
process.env.PASSWORDLESS_LOGIN_CALLBACK_URI || '/passwordless-login-callback'
|
|
29
|
+
callbackURI:
|
|
30
|
+
process.env.PASSWORDLESS_LOGIN_CALLBACK_URI || '/passwordless-login-callback',
|
|
31
|
+
// The landing path for passwordless login
|
|
32
|
+
landingPath: '/passwordless-login-landing'
|
|
31
33
|
},
|
|
32
34
|
social: {
|
|
33
35
|
// Enables or disables social login for the site. Defaults to: false
|
|
@@ -43,7 +45,9 @@ module.exports = {
|
|
|
43
45
|
resetPassword: {
|
|
44
46
|
// The callback URI, which can be an absolute URL (including third-party URIs) or a relative path set up by the developer.
|
|
45
47
|
// If the env var `RESET_PASSWORD_CALLBACK_URI` is set, it will override the config value.
|
|
46
|
-
callbackURI: process.env.RESET_PASSWORD_CALLBACK_URI || '/reset-password-callback'
|
|
48
|
+
callbackURI: process.env.RESET_PASSWORD_CALLBACK_URI || '/reset-password-callback',
|
|
49
|
+
// The landing path for reset password
|
|
50
|
+
landingPath: '/reset-password-landing'
|
|
47
51
|
}
|
|
48
52
|
},
|
|
49
53
|
// The default site for your app. This value will be used when a siteRef could not be determined from the url
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@salesforce/pwa-kit-create-app",
|
|
3
|
-
"version": "3.9.0-preview.
|
|
3
|
+
"version": "3.9.0-preview.4",
|
|
4
4
|
"description": "Salesforce's project generator tool",
|
|
5
5
|
"homepage": "https://github.com/SalesforceCommerceCloud/pwa-kit/tree/develop/packages/pwa-kit-create-app#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -38,13 +38,13 @@
|
|
|
38
38
|
"tar": "^6.2.1"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
|
-
"@salesforce/pwa-kit-dev": "3.9.0-preview.
|
|
42
|
-
"internal-lib-build": "3.9.0-preview.
|
|
41
|
+
"@salesforce/pwa-kit-dev": "3.9.0-preview.4",
|
|
42
|
+
"internal-lib-build": "3.9.0-preview.4",
|
|
43
43
|
"verdaccio": "^5.22.1"
|
|
44
44
|
},
|
|
45
45
|
"engines": {
|
|
46
46
|
"node": "^16.11.0 || ^18.0.0 || ^20.0.0 || ^22.0.0",
|
|
47
47
|
"npm": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0"
|
|
48
48
|
},
|
|
49
|
-
"gitHead": "
|
|
49
|
+
"gitHead": "1f16c75dcb6939a4edc76bb477888239fa76357b"
|
|
50
50
|
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|