├── public ├── robots.txt ├── favicon.ico ├── logo192.png ├── logo512.png ├── manifest.json ├── oauthResponse.html ├── config_example.js └── index.html ├── src ├── assets │ ├── anchorfields.pdf │ ├── anchorfields_view.docx │ └── anchorfields_view.pdf ├── setupTests.js ├── index.css ├── reportWebVitals.js ├── index.js ├── App.test.js ├── App.css ├── logo.svg ├── DocuSign.js ├── OAuthImplicit.js └── App.js ├── .gitignore ├── LICENSE ├── package.json └── README.md /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/docusign/code-examples-react/master/public/favicon.ico -------------------------------------------------------------------------------- /public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/docusign/code-examples-react/master/public/logo192.png -------------------------------------------------------------------------------- /public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/docusign/code-examples-react/master/public/logo512.png -------------------------------------------------------------------------------- /src/assets/anchorfields.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/docusign/code-examples-react/master/src/assets/anchorfields.pdf -------------------------------------------------------------------------------- /src/assets/anchorfields_view.docx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/docusign/code-examples-react/master/src/assets/anchorfields_view.docx -------------------------------------------------------------------------------- /src/assets/anchorfields_view.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/docusign/code-examples-react/master/src/assets/anchorfields_view.pdf -------------------------------------------------------------------------------- /src/setupTests.js: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom'; 6 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 12 | monospace; 13 | } 14 | -------------------------------------------------------------------------------- /src/reportWebVitals.js: -------------------------------------------------------------------------------- 1 | const reportWebVitals = onPerfEntry => { 2 | if (onPerfEntry && onPerfEntry instanceof Function) { 3 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { 4 | getCLS(onPerfEntry); 5 | getFID(onPerfEntry); 6 | getFCP(onPerfEntry); 7 | getLCP(onPerfEntry); 8 | getTTFB(onPerfEntry); 9 | }); 10 | } 11 | }; 12 | 13 | export default reportWebVitals; 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | public/config.js 4 | 5 | # dependencies 6 | /node_modules 7 | /.pnp 8 | .pnp.js 9 | 10 | # testing 11 | /coverage 12 | 13 | # production 14 | /build 15 | 16 | # misc 17 | .DS_Store 18 | .env.local 19 | .env.development.local 20 | .env.test.local 21 | .env.production.local 22 | 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import './index.css'; 4 | import App from './App'; 5 | import reportWebVitals from './reportWebVitals'; 6 | 7 | ReactDOM.render( 8 | 9 | 10 | , 11 | document.getElementById('root') 12 | ); 13 | 14 | // If you want to start measuring performance in your app, pass a function 15 | // to log results (for example: reportWebVitals(console.log)) 16 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals 17 | reportWebVitals(); 18 | -------------------------------------------------------------------------------- /src/App.test.js: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/react'; 2 | import App from './App'; 3 | import React from 'react'; 4 | import ReactDOM from 'react-dom'; 5 | 6 | test('renders login button', () => { 7 | render(); 8 | const loginEls = screen.getAllByText(/Login/i); 9 | //expect(linkElement).toBeInTheDocument(); 10 | expect(loginEls.length).toEqual(2); 11 | }); 12 | 13 | it('renders without crashing', () => { 14 | const div = document.createElement('div'); 15 | ReactDOM.render(, div); 16 | ReactDOM.unmountComponentAtNode(div); 17 | }); 18 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /public/oauthResponse.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | OAuth response handler 10 | 11 | 12 |

Please close this tab or window.

13 | 14 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2021-present DocuSign 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /public/config_example.js: -------------------------------------------------------------------------------- 1 | var config = {}; 2 | 3 | // Configuration file example. 4 | // 5 | // Development: Add to the public directory as config.js. 6 | // 7 | // Production: Add to the build directory after the build is 8 | // complete as config.js. 9 | 10 | // Your app's URL 11 | config.DS_APP_URL='http://localhost:3000/react-oauth-docusign/build'; 12 | // development url default is 'http://localhost:3000/react-oauth-docusign/build'; 13 | 14 | // If config.DS_REDIRECT_AUTHENTICATION is true, then add a 15 | // redirect URI to the integration key of DS_APP_URL since 16 | // the OAuth flow will restart the application 17 | // 18 | // If config.DS_REDIRECT_AUTHENTICATION is false, then add a 19 | // redirect URI to the integration key of 20 | // DS_APP_URL/oauthResponse.html 21 | 22 | config.DS_CLIENT_ID='xxxx-xxxx-xxxx-xxxx-xxxxxxx'; 23 | config.IMPLICIT_SCOPES='signature'; 24 | 25 | // DocuSign Identity server 26 | config.DS_IDP='https://account-d.docusign.com'; 27 | 28 | // Your DocuSign base URI 29 | config.DS_BASE_URI = 'https://demo.docusign.net'; 30 | 31 | // redirect authentication? 32 | // true: the app redirects to the IdP 33 | // false: the app opens a new tab for authentication, then closes it 34 | config.DS_REDIRECT_AUTHENTICATION=true; 35 | config.DS_DEBUG=true; 36 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "code-examples-react", 3 | "version": "0.1.0", 4 | "license": "MIT", 5 | "description": "React code example with OAuth Implicit grant and DocuSign API.", 6 | "homepage_comment": " is used by the build process as the build target URL path", 7 | "homepage": "code-examples-react/build", 8 | "private": true, 9 | "dependencies": { 10 | "@testing-library/jest-dom": "^5.11.4", 11 | "@testing-library/react": "^11.1.0", 12 | "@testing-library/user-event": "^12.1.10", 13 | "bootstrap": "^4.6.0", 14 | "react": "^17.0.2", 15 | "react-bootstrap": "^1.5.2", 16 | "react-dom": "^17.0.2", 17 | "react-scripts": "4.0.3", 18 | "react-toastify": "^7.0.3", 19 | "web-vitals": "^1.0.1" 20 | }, 21 | "scripts": { 22 | "start": "react-scripts start", 23 | "build": "react-scripts build", 24 | "test": "react-scripts test", 25 | "eject": "react-scripts eject" 26 | }, 27 | "eslintConfig": { 28 | "extends": [ 29 | "react-app", 30 | "react-app/jest" 31 | ] 32 | }, 33 | "browserslist": { 34 | "production": [ 35 | ">0.2%", 36 | "not dead", 37 | "not op_mini all" 38 | ], 39 | "development": [ 40 | "last 1 chrome version", 41 | "last 1 firefox version", 42 | "last 1 safari version" 43 | ] 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | .header { 2 | text-align: center; 3 | } 4 | 5 | .navbar-expand .navbar-nav .nav-link { 6 | padding-left: 0px; 7 | } 8 | 9 | .welcomeMargin { 10 | margin-top: 5rem; 11 | } 12 | 13 | .bodyMargin{ 14 | margin-top: 10rem; 15 | } 16 | 17 | /* From http://tobiasahlin.com/spinkit/ */ 18 | .spinner { 19 | width: 40px; 20 | height: 40px; 21 | background-color: blue; 22 | margin: 0px 0px 15px 0px; 23 | -webkit-animation: sk-rotateplane 1.2s infinite ease-in-out; 24 | animation: sk-rotateplane 1.2s infinite ease-in-out; 25 | } 26 | 27 | @-webkit-keyframes sk-rotateplane { 28 | 0% { -webkit-transform: perspective(120px) } 29 | 50% { -webkit-transform: perspective(120px) rotateY(180deg) } 30 | 100% { -webkit-transform: perspective(120px) rotateY(180deg) rotateX(180deg) } 31 | } 32 | 33 | @keyframes sk-rotateplane { 34 | 0% { 35 | transform: perspective(120px) rotateX(0deg) rotateY(0deg); 36 | -webkit-transform: perspective(120px) rotateX(0deg) rotateY(0deg) 37 | } 50% { 38 | transform: perspective(120px) rotateX(-180.1deg) rotateY(0deg); 39 | -webkit-transform: perspective(120px) rotateX(-180.1deg) rotateY(0deg) 40 | } 100% { 41 | transform: perspective(120px) rotateX(-180deg) rotateY(-179.9deg); 42 | -webkit-transform: perspective(120px) rotateX(-180deg) rotateY(-179.9deg); 43 | } 44 | } 45 | 46 | @import '~react-toastify/dist/ReactToastify.css'; 47 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 19 | 20 | 21 | 22 | 26 | 27 | 36 | React | OAuth Implicit Grant | DocuSign Example App 37 | 38 | 39 | 40 |
41 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /src/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # code-examples-react 2 | 3 | ## Installation 4 | * Install Node.js v12, yarn, and npm. 5 | * Clone, fork, or download as a zip file. 6 | * Enter the app's directory and type `yarn install`. 7 | * After configuration (see below), type `yarn start` to start the 8 | app in development mode. 9 | 10 | ## Configuration 11 | Create an integration key (client id) that enables **Implicit Grant**. 12 | The integration key does not need a secret nor an RSA private key. 13 | 14 | Decide if you want the application to redirect its browser tab for authentication 15 | to the DocuSign Identity Provider (IdP) or if it should open a new tab for authentication. 16 | 17 | Decide what the application's URL is. For development, the default 18 | application URL is 19 | `http://localhost:3000/code-examples-react/build` 20 | 21 | ### Redirect URIs 22 | Add one or two Redirect URIs to the integration key: 23 | * For redirecting to the IdP, add a Redirect URI that is the same as the application's URL. 24 | * For opening a new tab to the IdP, add a Redirect URI that is the application's URL with 25 | `/oauthResponse.html` appended. 26 | 27 | ### Private CORS proxies 28 | Create one or more private CORS proxies. See the 29 | [blog post](https://www.docusign.com/blog/dsdev-building-single-page-applications-with-docusign-and-cors-part-2). 30 | For nginx, see the [CORS proxy configuration file](https://github.com/docusign/blog-create-a-CORS-gateway/blob/master/nginx_site_file). 31 | 32 | You will add the proxy address or addresses to 33 | the config.js file (see below). 34 | 35 | If you'd like to use CORS in production, 36 | ask your DocuSign support contact to add your company 37 | name to PORTFOLIO-1100. This will help raise the prioritization 38 | of adding CORS to the eSignature API. 39 | 40 | ### Configuration file 41 | Copy the file `public/config_example.js` to `public/config.js` and fill in the settings. 42 | 43 | The config.js file should not be stored with the repository. 44 | 45 | ## Pull Requests and Questions 46 | Pull requests (PRs) are welcomed, all PRs must use the MIT license. 47 | 48 | If you have questions about this code example, please 49 | ask on StackOverflow, using the `docusignapi` tag. 50 | 51 | # Getting Started with Create React App 52 | (The following is the default Readme for apps built with the Create React App utility.) 53 | 54 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 55 | 56 | ## Available Scripts 57 | 58 | In the project directory, you can run: 59 | 60 | ### `yarn start` 61 | 62 | Runs the app in the development mode.\ 63 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 64 | 65 | The page will reload if you make edits.\ 66 | You will also see any lint errors in the console. 67 | 68 | ### `yarn test` 69 | 70 | Launches the test runner in the interactive watch mode.\ 71 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 72 | 73 | ### `yarn build` 74 | 75 | Builds the app for production to the `build` folder.\ 76 | It correctly bundles React in production mode and optimizes the build for the best performance. 77 | 78 | The build is minified and the filenames include the hashes.\ 79 | Your app is ready to be deployed! 80 | 81 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 82 | 83 | ### `yarn eject` 84 | 85 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!** 86 | 87 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. 88 | 89 | Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. 90 | 91 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. 92 | 93 | ## Learn More 94 | 95 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 96 | 97 | To learn React, check out the [React documentation](https://reactjs.org/). 98 | 99 | ### Code Splitting 100 | 101 | This section has moved here: [https://facebook.github.io/create-react-app/docs/code-splitting](https://facebook.github.io/create-react-app/docs/code-splitting) 102 | 103 | ### Analyzing the Bundle Size 104 | 105 | This section has moved here: [https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size](https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size) 106 | 107 | ### Making a Progressive Web App 108 | 109 | This section has moved here: [https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app](https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app) 110 | 111 | ### Advanced Configuration 112 | 113 | This section has moved here: [https://facebook.github.io/create-react-app/docs/advanced-configuration](https://facebook.github.io/create-react-app/docs/advanced-configuration) 114 | 115 | ### Deployment 116 | 117 | This section has moved here: [https://facebook.github.io/create-react-app/docs/deployment](https://facebook.github.io/create-react-app/docs/deployment) 118 | 119 | ### `yarn build` fails to minify 120 | 121 | This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify) 122 | -------------------------------------------------------------------------------- /src/DocuSign.js: -------------------------------------------------------------------------------- 1 | /** 2 | * DocuSign and related operations. 3 | */ 4 | import { toast } from 'react-toastify'; 5 | import anchorfields_pdf from './assets/anchorfields.pdf'; 6 | 7 | const docName = 'anchorfields.pdf'; 8 | const sdkString = 'codeEg_react'; 9 | const urlFrag = '/restapi/v2.1'; // DocuSign specific 10 | 11 | /** 12 | * Asset files 13 | * Add assets (eg PDF files) to the top level public directory. 14 | * (NOT under /src.) They will be included with the packaged app. 15 | * 16 | */ 17 | 18 | class DocuSign { 19 | // 20 | // constructor for the class 21 | // 22 | constructor(app) { 23 | this.app = app; 24 | this.sendEnvelope = this.sendEnvelope.bind(this); 25 | } 26 | 27 | // 28 | // Instance methods 29 | // 30 | 31 | /** 32 | * Send an envelope, return results or error 33 | */ 34 | async sendEnvelope() { 35 | // get the document 36 | let response = await fetch(anchorfields_pdf) 37 | if (!response || response.status !== 200) { 38 | const msg = `Could not fetch file ${anchorfields_pdf}`; 39 | console.log(msg); 40 | toast.error(msg, { autoClose: 10000 }); 41 | return; 42 | } 43 | // See https://stackoverflow.com/a/39951543/64904 44 | const fileBlob = await response.blob(); 45 | const reader = new FileReader(); 46 | await new Promise(resolve => { 47 | reader.onloadend = resolve; 48 | reader.readAsDataURL(fileBlob); 49 | }); 50 | const base64File = reader.result.split(',')[1]; 51 | 52 | const envelopeRequest = { 53 | emailSubject: 'Please sign the attached document', 54 | status: 'sent', 55 | recipients: { 56 | signers: [ 57 | { 58 | email: this.app.state.formEmail, 59 | name: this.app.state.formName, 60 | recipientId: '1', 61 | tabs: { 62 | signHereTabs: [ 63 | { 64 | anchorString: '/sn1/', 65 | anchorXOffset: '20', 66 | anchorUnits: 'pixels', 67 | }, 68 | ], 69 | }, 70 | }, 71 | ], 72 | }, 73 | documents: [ 74 | { 75 | name: docName, 76 | fileExtension: 'pdf', 77 | documentId: '1', 78 | documentBase64: base64File, 79 | }, 80 | ], 81 | }; 82 | 83 | try { 84 | const url = 85 | `${this.app.state.baseUri}${urlFrag}` + 86 | `/accounts/${this.app.state.accountId}` + 87 | `/envelopes`; 88 | const response = await fetch(url, { 89 | method: 'POST', 90 | body: JSON.stringify(envelopeRequest), 91 | headers: new Headers({ 92 | Authorization: `Bearer ${this.app.state.accessToken}`, 93 | Accept: `application/json`, 94 | 'Content-Type': 'application/json', 95 | 'X-DocuSign-SDK': sdkString, 96 | }), 97 | }); 98 | const data = response && response.ok && (await response.json()); 99 | let result; 100 | const headers = response.headers; 101 | const availableApiReqHeader = headers.get('X-RateLimit-Remaining'); 102 | const availableApiRequests = availableApiReqHeader 103 | ? parseInt(availableApiReqHeader, 10) 104 | : undefined; 105 | const apiResetHeader = headers.get('X-RateLimit-Reset'); 106 | const apiRequestsReset = apiResetHeader 107 | ? new Date(parseInt(apiResetHeader, 10) * 1000) 108 | : undefined; 109 | const traceId = headers.get('X-DocuSign-TraceToken') || undefined; 110 | if (response.ok) { 111 | result = { 112 | success: true, 113 | errorMsg: undefined, 114 | envelopeId: data.envelopeId, 115 | availableApiRequests, 116 | apiRequestsReset, 117 | traceId, 118 | }; 119 | } else { 120 | result = { 121 | success: false, 122 | errorMsg: response && (await response.text()), 123 | envelopeId: undefined, 124 | availableApiRequests, 125 | apiRequestsReset, 126 | traceId, 127 | }; 128 | } 129 | return result; 130 | } catch (e) { 131 | // Unfortunately we don't have access to the real 132 | // networking problem! 133 | // See https://medium.com/to-err-is-aaron/detect-network-failures-when-using-fetch-40a53d56e36 134 | const errorMsg = 135 | e.message === 'Failed to fetch' 136 | ? 'Networking error—check your Internet and DNS connections' 137 | : e.message; 138 | return { 139 | success: false, 140 | errorMsg, 141 | envelopeId: undefined, 142 | availableApiRequests: undefined, 143 | apiRequestsReset: undefined, 144 | traceId: undefined, 145 | }; 146 | } 147 | } 148 | 149 | /** 150 | * Get envelope's status, return results or error 151 | */ 152 | async getEnvelope() { 153 | try { 154 | const url = 155 | `${this.app.state.baseUri}${urlFrag}` + 156 | `/accounts/${this.app.state.accountId}` + 157 | `/envelopes/${this.app.state.responseEnvelopeId}`; 158 | const response = await fetch(url, { 159 | method: 'GET', 160 | headers: new Headers({ 161 | Authorization: `Bearer ${this.app.state.accessToken}`, 162 | Accept: `application/json`, 163 | 'Content-Type': 'application/json', 164 | 'X-DocuSign-SDK': sdkString, 165 | }), 166 | }); 167 | const data = response && response.ok && (await response.json()); 168 | let result; 169 | const headers = response.headers; 170 | const availableApiReqHeader = headers.get('X-RateLimit-Remaining'); 171 | const availableApiRequests = availableApiReqHeader 172 | ? parseInt(availableApiReqHeader, 10) 173 | : undefined; 174 | const apiResetHeader = headers.get('X-RateLimit-Reset'); 175 | const apiRequestsReset = apiResetHeader 176 | ? new Date(parseInt(apiResetHeader, 10) * 1000) 177 | : undefined; 178 | const traceId = headers.get('X-DocuSign-TraceToken') || undefined; 179 | if (response.ok) { 180 | result = { 181 | success: true, 182 | errorMsg: undefined, 183 | resultsEnvelopeJson: data, 184 | availableApiRequests, 185 | apiRequestsReset, 186 | traceId, 187 | }; 188 | } else { 189 | result = { 190 | success: false, 191 | errorMsg: response && (await response.text()), 192 | resultsEnvelopeJson: undefined, 193 | availableApiRequests, 194 | apiRequestsReset, 195 | traceId, 196 | }; 197 | } 198 | return result; 199 | } catch (e) { 200 | // Unfortunately we don't have access to the real 201 | // networking problem! 202 | // See https://medium.com/to-err-is-aaron/detect-network-failures-when-using-fetch-40a53d56e36 203 | const errorMsg = 204 | e.message === 'Failed to fetch' 205 | ? 'Networking error—check your Internet and DNS connections' 206 | : e.message; 207 | return { 208 | success: false, 209 | errorMsg, 210 | resultsEnvelopeJson: undefined, 211 | availableApiRequests: undefined, 212 | apiRequestsReset: undefined, 213 | traceId: undefined, 214 | }; 215 | } 216 | } 217 | 218 | } 219 | 220 | export default DocuSign; 221 | -------------------------------------------------------------------------------- /src/OAuthImplicit.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This file's functions are used for OAuthImplicit grant and 3 | * related authentication operations. 4 | */ 5 | import { toast } from 'react-toastify'; 6 | 7 | const oauthResponseHtml = 'oauthResponse.html'; // only used for new tab auth 8 | const expirationBuffer = 10 * 60; // 10 minute buffer 9 | const sdkString = 'codeEg_react'; 10 | const urlFrag = '/restapi/v2.1'; // DocuSign specific 11 | const log = m => {console.log(m)} 12 | const oauthState = 'oauthState'; // The name of the localStorage item for the OAuth state parameter 13 | 14 | class OAuthImplicit { 15 | // 16 | // Static methods 17 | // 18 | /** 19 | * Generate a psuedo random string 20 | * See https://stackoverflow.com/a/27747377/64904 21 | * @param {integer} len length of the returned string 22 | */ 23 | static generateId(len = 40) { 24 | // dec2hex :: Integer -> String i.e. 0-255 -> '00'-'ff' 25 | const arr = new Uint8Array((len || 40) / 2); 26 | function dec2hex(dec) { 27 | return `0${dec.toString(16)}`.substr(-2); 28 | } 29 | window.crypto.getRandomValues(arr); 30 | return Array.from(arr, dec2hex).join(''); 31 | } 32 | 33 | // 34 | // constructor for the class 35 | // 36 | constructor(app) { 37 | this.app = app; 38 | this.oauthWindow = null; // only used for new tab auth 39 | } 40 | 41 | /** 42 | * Handle incoming OAuth Implicit grant response 43 | */ 44 | async receiveHash(hash) { 45 | const config = window.config; 46 | const accessTokenFound = hash && hash.substring(0,14) === '#access_token='; 47 | if (!accessTokenFound) {return} // EARLY RETURN 48 | 49 | // Avoiding an injection attack: check that the hash only includes expected characters 50 | // An example: #access_token=eyJ0eXA...[Access tokens can be 610 characters or longer]...wKVQLqF6A&expires_in=28800&token_type=bearer&state=e3f287fbe93...c58bd6a67fe2 51 | // No characters other than #.-&=_ a-z A-Z 0-9 (no spaces) 52 | const hashRegex = /[^#.%\-&=_a-zA-Z0-9]/; 53 | if (hash.search(hashRegex) !== -1) { 54 | console.error (`Potential XSS attack via fragment (#) value: ${hash}`); 55 | toast.error('Potential XSS attack via the fragment value. Please login again.', { 56 | autoClose: 7000}); 57 | return 58 | } 59 | 60 | const oauthStateValue = window.localStorage.getItem(oauthState); 61 | const regex = /(#access_token=)(.*)(&expires_in=)(.*)(&token_type=)(.*)(&state=)(.*)/ 62 | , results = regex.exec(hash) 63 | , accessToken = results[2] 64 | , expiresIn = results[4] 65 | , incomingState = results[8] 66 | , stateOk = incomingState === oauthStateValue 67 | ; 68 | if (!stateOk) { 69 | toast.error('State error during login. Please login again.', { 70 | autoClose: 10000}); 71 | console.error(`OAuth state mismatch!! Expected state: ${oauthStateValue}; received state: ${incomingState}`); 72 | return // EARLY RETURN 73 | } 74 | window.localStorage.clear(); // clean up 75 | 76 | if (config.DS_REDIRECT_AUTHENTICATION) { 77 | // Using redirect the window authentication: 78 | // hash was good, so erase it from the browser 79 | window.history.replaceState(null, '', config.DS_APP_URL); 80 | } else { 81 | // Using new tab authentication: 82 | // close the tab that was used for authentication 83 | if (this.oauthWindow) {this.oauthWindow.close()} 84 | } 85 | 86 | // calculate expires 87 | let expires = new Date() 88 | expires.setTime(expires.getTime() + (expiresIn - expirationBuffer)* 1000) 89 | this.accessToken = accessToken; 90 | 91 | const toastId = toast.success('Completing the login process...', {autoClose: 7000}); 92 | 93 | // call /oauth/userinfo for general user info 94 | // This API method is common for many IdP systems. 95 | // But the exact format of the response tends to vary. 96 | // The following works for the DocuSign IdP. 97 | const userInfo = await this.fetchUserInfo(); 98 | const defaultAccountArray = userInfo.accounts.filter((acc) => acc.is_default); 99 | const defaultAccount = defaultAccountArray.length > 0 && defaultAccountArray[0]; 100 | if (!defaultAccount) { 101 | const msg = `Problem: the user does not have a default account. Contact DocuSign Customer Service to fix.`; 102 | log(msg); 103 | toast.error(msg, { autoClose: 10000 }); 104 | return; 105 | } 106 | // 107 | // Need to select the right proxy for the API call 108 | // update the baseUri setting 109 | let baseUri = config.DS_BASE_URI; 110 | if (!baseUri) { 111 | const msg = `Problem: no proxy for ${defaultAccount.base_uri}.`; 112 | log(msg); 113 | toast.error(msg, { autoClose: 10000 }); 114 | return; 115 | } 116 | 117 | const externalAccountId = await this.getExternalAccountId( 118 | defaultAccount.account_id, baseUri); 119 | toast.dismiss(toastId); 120 | this.app.oAuthResults({ 121 | accessToken, 122 | expires, 123 | name: userInfo.name, 124 | email: userInfo.email, 125 | accountId: defaultAccount.account_id, 126 | externalAccountId, 127 | accountName: defaultAccount.account_name, 128 | baseUri: baseUri, 129 | }) 130 | } 131 | 132 | /** 133 | * Start the login flow by computing the Implicit grant URL 134 | * and either redirecting to the URL for the user or 135 | * creating a new browser tab for the authentication flow 136 | */ 137 | startLogin() { 138 | const config = window.config; 139 | const oauthStateValue = OAuthImplicit.generateId(); 140 | window.localStorage.setItem(oauthState, oauthStateValue); // store for when we come back 141 | let redirectUrl; 142 | if (config.DS_REDIRECT_AUTHENTICATION) { 143 | // Using redirect the window authentication: 144 | redirectUrl = config.DS_APP_URL; 145 | } else { 146 | // Using new tab authentication 147 | redirectUrl = `${config.DS_APP_URL}/${oauthResponseHtml}`; 148 | } 149 | 150 | const url = 151 | `${window.config.DS_IDP}/oauth/auth?` + 152 | `response_type=token&` + 153 | `scope=${window.config.IMPLICIT_SCOPES}&` + 154 | `client_id=${window.config.DS_CLIENT_ID}&` + 155 | `state=${oauthStateValue}&` + 156 | `redirect_uri=${encodeURIComponent(redirectUrl)}`; 157 | 158 | if (config.DS_REDIRECT_AUTHENTICATION) { 159 | // Using redirect the window authentication: 160 | window.location = url; 161 | } else { 162 | // Using new tab authentication: 163 | // Create a new tab for authentication 164 | this.oauthWindow = window.open(url, "_blank"); 165 | } 166 | } 167 | 168 | /** 169 | * logout of the DocuSign IdP. 170 | * If SSO is used, the upstream IdP may not redirect the 171 | * browser back to this app 172 | */ 173 | logout () { 174 | const config = window.config; 175 | const url = 176 | `${window.config.DS_IDP}/logout?` + 177 | `response_type=token&` + 178 | `scope=${config.IMPLICIT_SCOPES}&` + 179 | `client_id=${config.DS_CLIENT_ID}&` + 180 | `redirect_uri=${encodeURIComponent(config.DS_APP_URL)}&` + 181 | `response_mode=logout_redirect`; 182 | window.location = url; 183 | } 184 | 185 | /** 186 | * A relatively common OAuth API endpoint for obtaining information 187 | * on the user associated with the accessToken 188 | * @returns userInfoResponse JSON 189 | */ 190 | async fetchUserInfo() { 191 | let userInfoResponse 192 | try { 193 | userInfoResponse = await fetch( 194 | `${window.config.DS_IDP}/oauth/userinfo`, { 195 | headers: new Headers({ 196 | Authorization: `Bearer ${this.accessToken}`, 197 | Accept: `application/json`, 198 | 'X-DocuSign-SDK': sdkString, 199 | }), 200 | }) 201 | } catch (e) { 202 | const msg = `Problem while completing login.\nPlease retry.\nError: ${e.toString()}`; 203 | log(msg); 204 | toast.error(msg, { autoClose: 10000 }); 205 | return null; 206 | } 207 | if (!userInfoResponse || !userInfoResponse.ok) { 208 | const msg = `Problem while completing login.\nPlease retry.\nError: ${userInfoResponse.statusText}`; 209 | log(msg); 210 | toast.error(msg, { autoClose: 10000 }); 211 | return null; 212 | } 213 | return await userInfoResponse.json(); 214 | } 215 | 216 | /** 217 | * Fetch the user-friendly version of the accountId. 218 | * See https://developers.docusign.com/docs/esign-rest-api/reference/accounts/accounts/get/ 219 | */ 220 | async getExternalAccountId(accountId, baseUri) { 221 | try { 222 | const url = `${baseUri}${urlFrag}/accounts/${accountId}`; 223 | const response = await fetch(url, { 224 | method: 'GET', 225 | headers: new Headers({ 226 | Authorization: `Bearer ${this.accessToken}`, 227 | Accept: `application/json`, 228 | 'X-DocuSign-SDK': sdkString, 229 | }) 230 | }); 231 | const data = response && response.ok && (await response.json()); 232 | return data.externalAccountId; 233 | } catch (e) { 234 | return null; 235 | } 236 | } 237 | } 238 | 239 | export default OAuthImplicit; 240 | -------------------------------------------------------------------------------- /src/App.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Container from 'react-bootstrap/Container'; 3 | import Row from 'react-bootstrap/Row'; 4 | import Col from 'react-bootstrap/Col'; 5 | import Button from 'react-bootstrap/Button'; 6 | import Form from 'react-bootstrap/Form'; 7 | import Jumbotron from 'react-bootstrap/Jumbotron'; 8 | import Navbar from 'react-bootstrap/Navbar'; 9 | import Nav from 'react-bootstrap/Nav'; 10 | import { ToastContainer, toast } from 'react-toastify'; 11 | import OAuthImplicit from './OAuthImplicit'; 12 | import DocuSign from './DocuSign'; 13 | import './App.css'; 14 | 15 | class App extends React.Component { 16 | constructor(props) { 17 | super(props); 18 | this.state = { 19 | accessToken: undefined, 20 | expires: undefined, 21 | name: undefined, 22 | email: undefined, 23 | externalAccountId: undefined, 24 | accountName: undefined, 25 | accountId: undefined, 26 | baseUri: undefined, 27 | page: 'welcome', // initial page. Pages: welcome|loggedIn 28 | working: false, 29 | workingMessage: '', 30 | responseErrorMsg: undefined, 31 | responseEnvelopeId: undefined, 32 | responseAvailableApiRequests: undefined, 33 | responseApiRequestsReset: undefined, 34 | responseSuccess: undefined, 35 | responseTraceId: undefined, 36 | resultsEnvelopeJson: undefined, 37 | formName: '', 38 | formEmail: '', 39 | }; 40 | this.oAuthImplicit = new OAuthImplicit(this); 41 | this.docusign = new DocuSign(this); 42 | 43 | // bind for methods called by React via buttons, etc 44 | this.logout = this.logout.bind(this); 45 | this.startAuthentication = this.startAuthentication.bind(this); 46 | this.formNameChange = this.formNameChange.bind(this); 47 | this.formEmailChange = this.formEmailChange.bind(this); 48 | this.sendEnvelope = this.sendEnvelope.bind(this); 49 | this.getEnvelope = this.getEnvelope.bind(this); 50 | this.receiveMessage = this.receiveMessage.bind(this); 51 | } 52 | 53 | /** 54 | * Starting up--if our URL includes a hash, check it to see if 55 | * it's the OAuth response 56 | */ 57 | async componentDidMount() { 58 | const config = window.config; 59 | // if the url has a query parameter of ?error=logout_request (from a logout operation) 60 | // then remove it 61 | if (window.location.search && window.location.search === '?error=logout_request') { 62 | window.history.replaceState(null, '', config.DS_APP_URL); 63 | } 64 | 65 | if (config?.DS_REDIRECT_AUTHENTICATION) { 66 | const hash = window.location.hash; 67 | if (!hash) {return} 68 | // possible OAuth response 69 | this.setState({working: true, workingMessage: 'Logging in'}); 70 | await this.oAuthImplicit.receiveHash(hash); 71 | this.setState({working: false}); 72 | } else { 73 | // await authentication via the new tab 74 | window.addEventListener("message", this.receiveMessage, false); 75 | } 76 | } 77 | 78 | /** 79 | * Receive message from a child . 80 | * This method is only used if authentication is done 81 | * in a new tab. See file public/oauthResponse.html 82 | * @param {object} e 83 | */ 84 | async receiveMessage(e) { 85 | const rawSource = e && e.data && e.data.source 86 | , ignore = {'react-devtools-inject-backend': true, 87 | 'react-devtools-content-script': true, 88 | 'react-devtools-detector': true, 89 | 'react-devtools-bridge': true} 90 | , source = (rawSource && !ignore[rawSource]) ? rawSource : false 91 | ; 92 | if (!source) {return}; // Ignore if no source field 93 | if (source === 'oauthResponse') { 94 | this.setState({working: true, workingMessage: 'Logging in'}); 95 | const hash = e.data && e.data.hash; 96 | await this.oAuthImplicit.receiveHash(hash); 97 | this.setState ({working: false}); 98 | } 99 | } 100 | 101 | startAuthentication() { 102 | this.oAuthImplicit.startLogin(); 103 | } 104 | 105 | /** 106 | * Is the accessToken ok to use? 107 | * @returns boolean accessTokenIsGood 108 | */ 109 | checkToken() { 110 | if ( 111 | !this.state.accessToken || 112 | this.state.expires === undefined || 113 | new Date() > this.state.expires 114 | ) { 115 | // Need new login. Only clear auth, don't clear the state (leave form contents); 116 | this.clearAuth(); 117 | this.setState({ page: 'welcome', working: false }); 118 | toast.error('Your login session has ended.\nPlease login again', { 119 | autoClose: 8000, 120 | }); 121 | return false; 122 | } 123 | return true; 124 | } 125 | 126 | /** 127 | * This method clears this app's authentication information. 128 | * But there may still be an active login session cookie 129 | * from the IdP. Your IdP may have an API method for clearing 130 | * the login session. 131 | */ 132 | logout() { 133 | this.clearAuth(); 134 | this.clearState(); 135 | this.setState({ page: 'welcome' }); 136 | toast.success('You have logged out.', { autoClose: 5000 }); 137 | this.oAuthImplicit.logout(); 138 | } 139 | 140 | /** 141 | * Clear authentication-related state 142 | */ 143 | clearAuth() { 144 | this.setState({ 145 | accessToken: undefined, 146 | expires: undefined, 147 | accountId: undefined, 148 | externalAccountId: undefined, 149 | accountName: undefined, 150 | baseUri: undefined, 151 | name: undefined, 152 | email: undefined, 153 | }) 154 | } 155 | 156 | /** 157 | * Clear the app's form and related state 158 | */ 159 | clearState() { 160 | this.setState({ 161 | formName: '', 162 | formEmail: '', 163 | working: false, 164 | responseErrorMsg: undefined, 165 | responseEnvelopeId: undefined, 166 | responseAvailableApiRequests: undefined, 167 | responseApiRequestsReset: undefined, 168 | responseSuccess: undefined, 169 | responseTraceId: undefined, 170 | resultsEnvelopeJson: undefined, 171 | }); 172 | } 173 | 174 | /** 175 | * Process the oauth results. 176 | * This method is called by the OAuthImplicit class 177 | * @param results 178 | */ 179 | oAuthResults(results) { 180 | this.setState({ 181 | accessToken: results.accessToken, expires: results.expires, 182 | name: results.name, externalAccountId: results.externalAccountId, 183 | email: results.email, accountId: results.accountId, 184 | accountName: results.accountName, baseUri: results.baseUri, 185 | page: 'loggedIn', 186 | formName: results.name, // default: set to logged in user 187 | formEmail: results.email, 188 | }); 189 | 190 | toast.success(`Welcome ${results.name}, you are now logged in`); 191 | } 192 | 193 | formNameChange(event) { 194 | this.setState({ formName: event.target.value }); 195 | } 196 | 197 | formEmailChange(event) { 198 | this.setState({ formEmail: event.target.value }); 199 | } 200 | 201 | async sendEnvelope() { 202 | this.setState({ 203 | responseErrorMsg: undefined, 204 | responseEnvelopeId: undefined, 205 | responseAvailableApiRequests: undefined, 206 | responseApiRequestsReset: undefined, 207 | responseSuccess: undefined, 208 | responseTraceId: undefined, 209 | resultsEnvelopeJson: undefined, 210 | }); 211 | if (!this.checkToken()) { 212 | return; // Problem! The user needs to login 213 | } 214 | if (!this.state.formEmail || this.state.formEmail.length < 5) { 215 | toast.error("Problem: Enter the signer's email address"); 216 | return; 217 | } 218 | if (!this.state.formName || this.state.formName.length < 5) { 219 | toast.error("Problem: Enter the signer's name"); 220 | return; 221 | } 222 | 223 | this.setState({ working: true, workingMessage: "Sending envelope" }); 224 | const results = await this.docusign.sendEnvelope(); 225 | const { apiRequestsReset } = results; 226 | const responseApiRequestsReset = apiRequestsReset ? 227 | new Date(apiRequestsReset) : undefined; 228 | this.setState({ 229 | working: false, 230 | responseSuccess: results.success, 231 | responseErrorMsg: results.errorMsg, 232 | responseEnvelopeId: results.envelopeId, 233 | responseAvailableApiRequests: results.availableApiRequests, 234 | responseTraceId: results.traceId, 235 | responseApiRequestsReset, 236 | }); 237 | } 238 | 239 | async getEnvelope() { 240 | this.setState({ 241 | responseErrorMsg: undefined, 242 | responseEnvelopeId: undefined, 243 | responseAvailableApiRequests: undefined, 244 | responseApiRequestsReset: undefined, 245 | responseSuccess: undefined, 246 | responseTraceId: undefined, 247 | }); 248 | if (!this.checkToken()) { 249 | return; // Problem! The user needs to login 250 | } 251 | if (!this.state.responseEnvelopeId) { 252 | toast.error("Problem: First send an envelope"); 253 | return; 254 | } 255 | 256 | this.setState({ working: true, workingMessage: "Fetching the envelope's status" }); 257 | const results = await this.docusign.getEnvelope(); 258 | const { apiRequestsReset } = results; 259 | const responseApiRequestsReset = apiRequestsReset 260 | ? new Date(apiRequestsReset) : undefined; 261 | this.setState({ 262 | working: false, 263 | responseSuccess: results.success, 264 | responseErrorMsg: results.errorMsg, 265 | responseAvailableApiRequests: results.availableApiRequests, 266 | responseTraceId: results.traceId, 267 | resultsEnvelopeJson: results.resultsEnvelopeJson, 268 | responseApiRequestsReset, 269 | }); 270 | } 271 | 272 | /** 273 | * Render this component 274 | */ 275 | render() { 276 | // Just two pages with a common header. 277 | // Choose the body of the page: 278 | let pagebody; 279 | switch (this.state.page) { 280 | case 'welcome': // not logged in 281 | pagebody = this.Welcome(); 282 | break; 283 | case 'loggedIn': 284 | pagebody = this.LoggedIn(); 285 | break; 286 | default: 287 | pagebody = this.Welcome(); 288 | }; 289 | 290 | // Compute the name block for the top nav section 291 | let nameBlock; 292 | if (this.state.accessToken) { 293 | nameBlock = ( 294 | 295 | {this.state.name}
296 | {this.state.accountName} ({this.state.externalAccountId}) 297 | 300 |
301 | ) 302 | } else { 303 | nameBlock = null; 304 | } 305 | 306 | // The spinner 307 | const spinner = ( 308 | 310 | 311 |
312 | 313 | 314 |

{this.state.workingMessage}…

315 |
316 | 317 | ) 318 | 319 | // The complete page: 320 | return ( 321 | <> 322 | 323 | DocuSign Code Example 324 | 325 | {nameBlock} 326 | 327 | 328 | {spinner} 329 | {pagebody} 330 | 331 | ) 332 | } 333 | 334 | LoggedIn() { 335 | const resetTime = this.state.responseApiRequestsReset; 336 | const resetTimeString = resetTime 337 | ? new Intl.DateTimeFormat('en-US', { 338 | dateStyle: 'medium', 339 | timeStyle: 'full', 340 | }).format(resetTime) 341 | : undefined; 342 | return ( 343 | 344 | 345 | 346 |

Send an Envelope

347 |
348 | 349 | Name 350 | 355 | 356 | 357 | Email 358 | 362 | 363 | 364 | 367 | 370 |
371 | 372 |
373 | 374 | 375 |

Results

376 |

377 | {this.state.responseSuccess !== undefined ? ( 378 | this.state.responseSuccess ? ( 379 | <>✅ Success! 380 | ) : ( 381 | <>❌ Problem! 382 | ) 383 | ) : null} 384 |

385 | {this.state.responseErrorMsg ? ( 386 |

Error message: {this.state.responseErrorMsg}

387 | ) : null} 388 | {this.state.responseEnvelopeId ? ( 389 |

Envelope ID: {this.state.responseEnvelopeId}

390 | ) : null} 391 | {this.state.resultsEnvelopeJson ? ( 392 |

Response: {JSON.stringify(this.state.resultsEnvelopeJson, null, 4)}

393 | ) : null} 394 | {this.state.responseAvailableApiRequests ? ( 395 |

396 | Available API requests: {this.state.responseAvailableApiRequests} 397 |

398 | ) : null} 399 | {resetTimeString ? ( 400 |

API requests reset time: {resetTimeString}

401 | ) : null} 402 | {this.state.responseTraceId ? ( 403 |

404 | Trace ID: {this.state.responseTraceId}. Please include with all 405 | customer service questions. 406 |

407 | ) : null} 408 | 409 |
410 |
411 | )} 412 | 413 | Welcome() { 414 | return ( 415 | 416 | 417 | 418 | 419 |

React Example with OAuth Authentication

420 |

421 | In this example the user authenticates with DocuSign via the OAuth Implicit grant flow. 422 | Since the app will then have an access token for the user, the app can call any 423 | DocuSign eSignature REST API method. 424 |

425 |

426 | Use this example for apps used by the staff of your organization who have 427 | DocuSign accounts. For example, an application could pull data from multiple 428 | sources and then send an envelope that includes the data. 429 |

430 |

431 | Login with your DocuSign Developer (Demo) credentials. 432 |

433 |

434 | 435 |

436 |
437 | 438 |
439 |
440 | ) 441 | } 442 | } 443 | export default App; 444 | --------------------------------------------------------------------------------