diff --git a/package-lock.json b/package-lock.json index 56c61a6..8849693 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "html2canvas": "^1.4.1", "i18next": "^23.7.18", "i18next-http-backend": "^2.4.2", + "js-sha256": "^0.11.0", "prop-types": "^15.8.1", "react": "^18.2.0", "react-bootstrap": "^2.10.0", @@ -12224,6 +12225,11 @@ "jiti": "bin/jiti.js" } }, + "node_modules/js-sha256": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/js-sha256/-/js-sha256-0.11.0.tgz", + "integrity": "sha512-6xNlKayMZvds9h1Y1VWc0fQHQ82BxTXizWPEtEeGvmOUYpBRy4gbWroHLpzowe6xiQhHpelCQiE7HEdznyBL9Q==" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -17595,16 +17601,16 @@ } }, "node_modules/typescript": { - "version": "5.4.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.3.tgz", - "integrity": "sha512-KrPd3PKaCLr78MalgiwJnA25Nm8HAmdwN3mYUYZgG/wizIo9EainNVQI9/yDavtVFRN2h3k8uf3GLHuhDMgEHg==", + "version": "4.9.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", + "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" }, "engines": { - "node": ">=14.17" + "node": ">=4.2.0" } }, "node_modules/unbox-primitive": { diff --git a/package.json b/package.json index 7eca6b0..cc24828 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "html2canvas": "^1.4.1", "i18next": "^23.7.18", "i18next-http-backend": "^2.4.2", + "js-sha256": "^0.11.0", "prop-types": "^15.8.1", "react": "^18.2.0", "react-bootstrap": "^2.10.0", diff --git a/public/index.html b/public/index.html index ebdc304..a0a90ff 100644 --- a/public/index.html +++ b/public/index.html @@ -1,6 +1,7 @@
+ diff --git a/public/locales/en/EmbeddedSigningTemplate.json b/public/locales/en/EmbeddedSigningTemplate.json deleted file mode 100644 index fd2cacb..0000000 --- a/public/locales/en/EmbeddedSigningTemplate.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "Title": "Enter your personal information:", - "FullName": "Full name:", - "StreetAddress": "Street address:", - "City": "City:", - "State": "State:", - "ZipCode": "Zip code:", - "Phone": "Phone:", - "Email": "Email:" -} \ No newline at end of file diff --git a/public/locales/en/KnowYourCustomer.json b/public/locales/en/KnowYourCustomer.json index 352c9d7..aa577d3 100644 --- a/public/locales/en/KnowYourCustomer.json +++ b/public/locales/en/KnowYourCustomer.json @@ -44,5 +44,15 @@ "WarningModal": { "Title": "Alert: Camera not found", "Description": "It appears your device doesn’t have a camera! For demonstration purposes, we’ll provide a generic image." + }, + "FocusedView": { + "Title": "Enter your personal information:", + "FullName": "Full name:", + "StreetAddress": "Street address:", + "City": "City:", + "State": "State:", + "ZipCode": "Zip code:", + "Phone": "Phone:", + "Email": "Email:" } } diff --git a/src/api/apiFactory.js b/src/api/apiFactory.js index f79483b..d83f930 100644 --- a/src/api/apiFactory.js +++ b/src/api/apiFactory.js @@ -1,6 +1,8 @@ /* eslint-disable no-param-reassign */ +import { sha256 } from "js-sha256"; import { getAuthToken } from "../services/accountRepository"; + const configureInterceptors = (api) => { // Request interceptor for API calls api.interceptors.request.use( @@ -10,7 +12,10 @@ const configureInterceptors = (api) => { "Content-Type": "application/json", }; const accessTokenInfo = getAuthToken(); - config.headers.Authorization = `Bearer ${accessTokenInfo.accessToken}`; + + if (accessTokenInfo?.accessToken) { + config.headers.Authorization = `Bearer ${accessTokenInfo.accessToken}`; + } return config; }, (error) => { @@ -116,25 +121,6 @@ export const createDocumentAPI = ( return response.status; }; - const createEnvelop = async (templateId, signerEmail, signerName) => { - const requestData = { - templateId, - templateRoles: [ - { - email: signerEmail, - name: signerName, - roleName: "signer", - }, - ], - status: "created", - }; - const response = await api.post( - `${accountBaseUrl}${eSignBase}/accounts/${accountId}/envelopes`, - requestData - ); - return response.data.envelopeId; - }; - const getDocumentId = async (envelopeId) => { const response = await api.get( `${accountBaseUrl}${eSignBase}/accounts/${accountId}/envelopes/${envelopeId}/docGenFormFields` @@ -189,14 +175,13 @@ export const createDocumentAPI = ( createTemplate, addDocumentToTemplate, addTabsToTemplate, - createEnvelop, getDocumentId, updateFormFields, - sendEnvelop, + sendEnvelop }; }; -export const createEmbeddedSigningAPI = ( +export const createFocusedViewAPI = ( axios, eSignBase, dsReturnUrl, @@ -205,37 +190,7 @@ export const createEmbeddedSigningAPI = ( ) => { const api = createAPI(axios); - const createEnvelope = async (htmlDoc, signer) => { - const requestData = { - emailSubject: - process.env.REACT_APP_EMBEDDED_DOCUMENT_TEMPLATE_EMAIL_SUBJECT, - description: process.env.REACT_APP_EMBEDDED_DOCUMENT_TEMPLATE_DESCRIPTION, - name: process.env.REACT_APP_EMBEDDED_DOCUMENT_TEMPLATE_NAME, - shared: false, - status: "sent", - recipients: { - signers: [ - { - email: signer.email, - name: signer.name, - recipientId: "1", - clientUserId: 1000, - roleName: "signer", - routingOrder: "1", - }, - ], - }, - documents: [ - { - name: process.env.REACT_APP_EMBEDDED_DOCUMENT_NAME, - documentId: 1, - htmlDefinition: { - source: htmlDoc, - }, - }, - ], - }; - + const createEnvelope = async (requestData) => { const response = await api.post( `${accountBaseUrl}${eSignBase}/accounts/${accountId}/envelopes`, requestData @@ -243,15 +198,8 @@ export const createEmbeddedSigningAPI = ( return response.data.envelopeId; }; - const embeddedSigningCeremony = async (envelopeId, signer) => { - const requestData = { - returnUrl: dsReturnUrl, - authenticationMethod: "None", - clientUserId: 1000, - email: signer.email, - userName: signer.name, - }; + const getRecipientView = async (envelopeId, requestData) => { const response = await api.post( `${accountBaseUrl}${eSignBase}/accounts/${accountId}/envelopes/${envelopeId}/views/recipient`, requestData @@ -260,30 +208,34 @@ export const createEmbeddedSigningAPI = ( return response.data.url; }; - const embeddedSigning = async (signer, template, onPopupIsBlocked) => { - const envelopeId = await createEnvelope(template, signer); - const url = await embeddedSigningCeremony(envelopeId, signer); - - const signingWindow = window.open(url, "_blank"); - const newTab = signingWindow; - if (!newTab || newTab.closed || typeof newTab.closed === "undefined") { - // POPUP BLOCKED - onPopupIsBlocked(); - return false; - } - signingWindow.focus(); - return signingWindow; - }; - return { - embeddedSigning, + getRecipientView, + createEnvelope }; }; +export const generateCodeVerifier = () => { + const randomValues = new Uint8Array(32); + window.crypto.getRandomValues(randomValues); + return btoa(String.fromCharCode.apply(null, randomValues)) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); +}; + +export const generateCodeChallenge = (codeVerifier) => { + const hash = sha256.arrayBuffer(codeVerifier); + const hashArray = Array.from(new Uint8Array(hash)); + return btoa(String.fromCharCode.apply(null, hashArray)) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); +}; + export const createAuthAPI = ( axios, serviceProvider, - implicitGrantPath, + authPath, userInfoPath, eSignBase, scopes, @@ -292,15 +244,21 @@ export const createAuthAPI = ( ) => { const api = createAPI(axios); + const codeVerifier = generateCodeVerifier(); + const codeChallenge = generateCodeChallenge(codeVerifier); + const login = async (nonce, onPopupIsBlocked) => { const url = - `${serviceProvider}${implicitGrantPath}` + - `?response_type=token` + + `${serviceProvider}${authPath}` + + `?response_type=code` + `&scope=${scopes}` + `&client_id=${clientId}` + `&redirect_uri=${returnUrl}` + - `&state=${nonce}`; + `&state=${nonce}` + + `&code_challenge_method=S256` + + `&code_challenge=${codeChallenge}`; const loginWindow = window.open(url, "_blank"); + const newTab = loginWindow; if (!newTab || newTab.closed || typeof newTab.closed === "undefined") { // POPUP BLOCKED @@ -311,6 +269,25 @@ export const createAuthAPI = ( return { window: loginWindow, nonce }; }; + const obtainAccessToken = async (code) => { + const requestData = { + code: `${code}`, + code_verifier: `${codeVerifier}`, + grant_type: "authorization_code", + client_id: `${clientId}`, + code_challenge_method: "S256", + redirect_uri: `${returnUrl}` + + }; + + const response = await api.post( + `${serviceProvider}/oauth/token`, + requestData + ); + + return response.data; + } + const fetchUserInfo = async () => { const response = await api.get(`${serviceProvider}${userInfoPath}`); return response.data; @@ -323,5 +300,5 @@ export const createAuthAPI = ( return response.data.externalAccountId; }; - return { login, fetchUserInfo, fetchExternalAccountId }; + return { login, fetchUserInfo, fetchExternalAccountId, obtainAccessToken }; }; diff --git a/src/api/index.js b/src/api/index.js index bddde56..6ace734 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -2,7 +2,7 @@ import axios from "axios"; import { createAuthAPI, createDocumentAPI, - createEmbeddedSigningAPI, + createFocusedViewAPI, } from "./apiFactory"; const authenticationAPI = createAuthAPI( @@ -24,8 +24,8 @@ const initDocumentAPI = (baseAccountUrl, accountId) => accountId ); -const initEmbeddedSigningAPI = (baseAccountUrl, accountId) => - createEmbeddedSigningAPI( +const initFocusedViewAPI = (baseAccountUrl, accountId) => + createFocusedViewAPI( axios, process.env.REACT_APP_ESIGN_BASE, process.env.REACT_APP_DS_RETURN_URL, @@ -33,4 +33,4 @@ const initEmbeddedSigningAPI = (baseAccountUrl, accountId) => accountId ); -export { authenticationAPI, initDocumentAPI, initEmbeddedSigningAPI }; +export { authenticationAPI, initDocumentAPI, initFocusedViewAPI }; diff --git a/src/components/header.js b/src/components/header.js index d275cd6..c978ef8 100644 --- a/src/components/header.js +++ b/src/components/header.js @@ -5,7 +5,7 @@ import { Navbar } from "react-bootstrap"; import PropTypes from "prop-types"; import logo from "../assets/img/logo.svg"; import * as accountRepository from "../services/accountRepository"; -import { logout } from "../hooks/useImplicitGrantAuth"; +import { logout } from "../hooks/usePckeAuth"; import useBreakpoint, { SIZE_MD, SIZE_SM } from "../hooks/useBreakpoint"; import whiteToggleIcon from "../assets/img/white-links.png"; import blackToggleIcon from "../assets/img/black-links.png"; diff --git a/src/hooks/useImplicitGrantAuth.js b/src/hooks/usePckeAuth.js similarity index 87% rename from src/hooks/useImplicitGrantAuth.js rename to src/hooks/usePckeAuth.js index bbde545..32db925 100644 --- a/src/hooks/useImplicitGrantAuth.js +++ b/src/hooks/usePckeAuth.js @@ -66,24 +66,30 @@ export const useImplicitGrantAuth = (onError, onSuccess) => { userEmail: userInfo.email, }); - const parseHash = (hash) => { - const items = hash.split(/=|&/); + const parseSearch = (search) => { + const queryString = search.startsWith('?') ? search.slice(1) : search; + const items = queryString.split(/=|&/); + let i = 0; const response = {}; + while (i + 1 < items.length) { - response[items[i]] = items[i + 1]; + response[decodeURIComponent(items[i])] = decodeURIComponent(items[i + 1]); i += 2; } + return response; }; + - const handleMessage = async (data) => { - // close the browser tab used for OAuth + const handleMessage = async () => { if (loginResult.current.window) { loginResult.current.window.close(); } - const hash = data.hash.substring(1); // remove the # - const response = parseHash(hash); + + const response = parseSearch(loginResult.current.window.location.search); + + const obtainAccessTokenResponse = await authenticationAPI.obtainAccessToken(response.code); const newState = response.state; if (newState !== loginResult.current.nonce) { @@ -93,8 +99,9 @@ export const useImplicitGrantAuth = (onError, onSuccess) => { }); return; } + const accessTokenInfo = { - accessToken: response.access_token, + accessToken: obtainAccessTokenResponse.access_token, accessTokenExpires: new Date(Date.now() + response.expires_in * 1000), }; accountRepository.setAuthToken(accessTokenInfo); diff --git a/src/pages/home/index.js b/src/pages/home/index.js index f88b01e..7d1c60f 100644 --- a/src/pages/home/index.js +++ b/src/pages/home/index.js @@ -6,7 +6,7 @@ import parse from "html-react-parser"; import { useImplicitGrantAuth, needToLogin -} from "../../hooks/useImplicitGrantAuth"; +} from "../../hooks/usePckeAuth"; import { Card, Layout, WaitingModal, MessageModal } from "../../components"; import { CTASection, TitleSection, ResoursesSection } from "./components"; diff --git a/src/pages/knowYourCustomer/components/focusedView.js b/src/pages/knowYourCustomer/components/focusedView.js new file mode 100644 index 0000000..df53a5f --- /dev/null +++ b/src/pages/knowYourCustomer/components/focusedView.js @@ -0,0 +1,150 @@ +import React, { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { initFocusedViewAPI } from "../../../api"; +import * as accountRepository from "../../../services/accountRepository"; +import { createSigningTemplate } from "../signingTemplate"; +import { WaitingModal } from "../../../components"; + +const createEnvelope = async (envelopeDefinition) => { + const accountInfo = accountRepository.getAccountInfo(); + const api = initFocusedViewAPI(accountInfo.accountBaseUrl, accountInfo.accountId); + const envelopeId = await api.createEnvelope(envelopeDefinition); + return envelopeId; +}; + +const createRecipientView = async (envelopeId, requestData) => { + const accountInfo = accountRepository.getAccountInfo(); + const api = initFocusedViewAPI(accountInfo.accountBaseUrl, accountInfo.accountId); + const recipientView = await api.getRecipientView(envelopeId, requestData); + return recipientView; +}; + +const createEnvelopeDefinition = async (signerEmail, signerName, signerClientId, userPhoto, t) => { + + const template = createSigningTemplate({name: signerName, email: signerEmail, photo: userPhoto}, t); + const document = { + name: process.env.REACT_APP_EMBEDDED_DOCUMENT_NAME, + documentId: 1, + htmlDefinition: { + source: template, + } + } + + const signHere1 = { + anchorString: "/sn1/", + anchorYOffset: "10", + anchorUnits: "pixels", + anchorXOffset: "20", + }; + + const signer1 = { + email: signerEmail, + name: signerName, + clientUserId: signerClientId, + recipientId: 1, + tabs: { + signHereTabs: [signHere1], + }, + }; + + return { + emailSubject: "Please sign this document", + documents: [document], + recipients: { signers: [signer1] }, + status: "sent", + }; +}; + +const createRecipientViewDefinition = (dsReturnUrl, signerEmail, signerName, signerClientId, dsPingUrl) => ({ + returnUrl: `${dsReturnUrl}`, + authenticationMethod: "none", + email: signerEmail, + userName: signerName, + clientUserId: signerClientId, + pingFrequency: 600, + pingUrl: dsPingUrl, + frameAncestors: [ window.location.origin, 'https://apps-d.docusign.com', 'https://demo.docusign.net'], + messageOrigins: ['https://apps-d.docusign.com'], +}); + +export const FocusedView = (args) => { + + const { t } = useTranslation("KnowYourCustomer"); + + const [showWaitingModal, setWaitingModal] = useState(false); + + useEffect(() => { + const fetchData = async () => { + try { + const { photo } = args; + setWaitingModal(true); + + const accountInfo = accountRepository.getAccountInfo(); + + const envelope = await createEnvelopeDefinition(accountInfo.userEmail, accountInfo.userName, 1000, photo, t); + + const originEnvelopeId = await createEnvelope(envelope); + + const recipientViewDefinition = createRecipientViewDefinition( + window.location.origin, + accountInfo.userEmail, + accountInfo.userName, + 1000, + window.location.origin + ); + + const recipientViewUrl = await createRecipientView(originEnvelopeId, recipientViewDefinition); + + const docusign = await window.DocuSign.loadDocuSign(process.env.REACT_APP_OAUTH_CLIENT_ID); + const signing = docusign.signing({ + url: recipientViewUrl, + displayFormat: "focused", + style: { + branding: { + primaryButton: { + backgroundColor: "#333", + color: "#fff", + }, + }, + signingNavigationButton: { + finishText: "You have finished the document! Continue!", + position: "bottom-center", + }, + }, + }); + + signing.on("sessionEnd", () => { + window.location.reload(); + }); + + signing.mount("#agreement"); + setWaitingModal(false); + } catch (error) { + console.error("Error fetching recipient view:", error); + } + }; + + fetchData(); + }, []); + + return ( +