├── 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 | You need to enable JavaScript to run this app.
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 | You need to enable JavaScript to run this app.
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 |
298 | this.logout()}>Logout
299 |
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 |
349 | Name
350 |
355 |
356 |
357 | Email
358 |
362 |
363 |
364 |
365 | Send Envelope
366 |
367 |
368 | Get Envelope Status
369 |
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 | Login
435 |
436 |
437 |
438 |
439 |
440 | )
441 | }
442 | }
443 | export default App;
444 |
--------------------------------------------------------------------------------