├── .changeset └── config.json ├── .commitlintrc.json ├── .eslintignore ├── .eslintrc.json ├── .github ├── actions │ └── lint-test-build │ │ └── action.yml └── workflows │ ├── pull-request.yml │ └── push.yml ├── .gitignore ├── .prettierrc ├── LICENSE ├── README.md ├── example ├── CHANGELOG.md ├── package.json ├── src │ ├── components │ │ ├── button.tsx │ │ ├── container.tsx │ │ ├── form.tsx │ │ ├── header.tsx │ │ ├── link.tsx │ │ ├── radio.tsx │ │ ├── section.tsx │ │ └── status.tsx │ ├── config │ │ └── index.ts │ ├── containers │ │ └── index.tsx │ ├── index.html │ ├── index.tsx │ └── modules │ │ ├── example.tsx │ │ └── options.tsx ├── tsconfig.json ├── webpack.common.js ├── webpack.dev.js └── webpack.prod.js ├── lib ├── CHANGELOG.md ├── README.md ├── index.test.tsx ├── index.tsx ├── package.json ├── tsconfig.json └── utils │ ├── injectScript.test.ts │ ├── injectScript.ts │ ├── isAnyScriptPresent.test.ts │ └── isAnyScriptPresent.ts ├── package.json ├── tsconfig.common.json ├── tsconfig.test.json ├── turbo.json └── yarn.lock /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@1.7.0/schema.json", 3 | "changelog": "@changesets/cli/changelog", 4 | "commit": false, 5 | "fixed": [], 6 | "linked": [], 7 | "access": "public", 8 | "baseBranch": "master", 9 | "updateInternalDependencies": "patch", 10 | "ignore": [] 11 | } 12 | -------------------------------------------------------------------------------- /.commitlintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["@commitlint/config-conventional"] 3 | } 4 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | node_modules/ 3 | *.d.ts 4 | webpack.*.js -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "extends": [ 4 | "eslint:recommended", 5 | "plugin:react/recommended", 6 | "plugin:@typescript-eslint/eslint-recommended", 7 | "plugin:@typescript-eslint/recommended" 8 | ], 9 | "plugins": ["react", "@typescript-eslint"], 10 | "settings": { 11 | "react": { 12 | "version": "detect" 13 | } 14 | }, 15 | "env": { 16 | "browser": true, 17 | "node": true 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /.github/actions/lint-test-build/action.yml: -------------------------------------------------------------------------------- 1 | name: lint-test-build 2 | 3 | description: Lint, test, build 4 | 5 | inputs: 6 | CODECOV_TOKEN: 7 | description: 'Codecov token' 8 | required: true 9 | 10 | runs: 11 | using: composite 12 | steps: 13 | - uses: actions/setup-node@v2 14 | with: 15 | node-version: '14' 16 | - id: yarn-cache-dir-path 17 | run: echo "::set-output name=dir::$(yarn cache dir)" 18 | shell: bash 19 | - uses: actions/cache@v2 20 | id: yarn-cache 21 | with: 22 | path: ${{ steps.yarn-cache-dir-path.outputs.dir }} 23 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 24 | restore-keys: | 25 | ${{ runner.os }}-yarn- 26 | - run: yarn install 27 | shell: bash 28 | - run: yarn lint 29 | shell: bash 30 | - run: yarn test:coverage 31 | shell: bash 32 | - uses: codecov/codecov-action@v2 33 | with: 34 | token: ${{ inputs.CODECOV_TOKEN }} 35 | directory: ./coverage 36 | - run: yarn build 37 | shell: bash 38 | -------------------------------------------------------------------------------- /.github/workflows/pull-request.yml: -------------------------------------------------------------------------------- 1 | name: pull-request 2 | 3 | on: pull_request 4 | 5 | jobs: 6 | lint-test-build: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v2 10 | - uses: ./.github/actions/lint-test-build 11 | with: 12 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 13 | -------------------------------------------------------------------------------- /.github/workflows/push.yml: -------------------------------------------------------------------------------- 1 | name: push 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | lint-test-build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | - uses: ./.github/actions/lint-test-build 14 | with: 15 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .turbo 2 | dist/ 3 | node_modules/ 4 | coverage/ 5 | *.log 6 | *.d.ts -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "none" 4 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Jakub Sarnowski 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | lib/README.md -------------------------------------------------------------------------------- /example/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | ## 1.12.0 4 | 5 | ### Minor Changes 6 | 7 | - 251fb14: Use react@18 and enable StrictMode 8 | 9 | ### Patch Changes 10 | 11 | - Updated dependencies [251fb14] 12 | - reaptcha@1.12.1 13 | 14 | ## 1.11.0 15 | 16 | ### Minor Changes 17 | 18 | - 9be85f7: rewrite example component to be a functional component 19 | 20 | ### Patch Changes 21 | 22 | - Updated dependencies [9be85f7] 23 | - reaptcha@1.11.0 24 | 25 | ## 1.10.0 26 | 27 | ### Minor Changes 28 | 29 | - cb8d8b0: fix various inconsistencies, support changing config dynamically 30 | 31 | ### Patch Changes 32 | 33 | - Updated dependencies [cb8d8b0] 34 | - reaptcha@1.10.0 35 | 36 | ## 1.9.0 37 | 38 | ### Minor Changes 39 | 40 | - 58afe5c: Migrate to TypeScript 41 | 42 | ### Patch Changes 43 | 44 | - Updated dependencies [58afe5c] 45 | - reaptcha@1.9.0 46 | 47 | ## 1.8.0 48 | 49 | ### Minor Changes 50 | 51 | - 8163760: Improved bundle, removed redundant dependencies 52 | 53 | ### Patch Changes 54 | 55 | - 03bf4ff: Migrate from lerna to turborepo + changeset 56 | - Updated dependencies [8163760] 57 | - Updated dependencies [03bf4ff] 58 | - reaptcha@1.8.0 59 | 60 | All notable changes to this project will be documented in this file. 61 | See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. 62 | 63 | ## [1.7.3](https://github.com/sarneeh/reaptcha/compare/v1.7.2...v1.7.3) (2021-09-03) 64 | 65 | **Note:** Version bump only for package reaptcha-example 66 | 67 | 68 | 69 | ## [1.7.2](https://github.com/sarneeh/reaptcha/compare/v1.7.1...v1.7.2) (2019-12-28) 70 | 71 | **Note:** Version bump only for package reaptcha-example 72 | 73 | 74 | 75 | ## [1.7.1](https://github.com/sarneeh/reaptcha/compare/v1.7.0...v1.7.1) (2019-12-28) 76 | 77 | ### Features 78 | 79 | - added getResponse example ([274e4cf](https://github.com/sarneeh/reaptcha/commit/274e4cf)) 80 | 81 | 82 | 83 | # [1.7.0](https://github.com/sarneeh/reaptcha/compare/v1.6.0...v1.7.0) (2019-10-20) 84 | 85 | **Note:** Version bump only for package reaptcha-example 86 | 87 | 88 | 89 | ## [1.5.1](https://github.com/sarneeh/reaptcha/compare/v1.5.0...v1.5.1) (2019-06-28) 90 | 91 | **Note:** Version bump only for package reaptcha-example 92 | 93 | 94 | 95 | # [1.5.0](https://github.com/sarneeh/reaptcha/compare/v1.4.2...v1.5.0) (2019-05-25) 96 | 97 | **Note:** Version bump only for package reaptcha-example 98 | 99 | 100 | 101 | ## [1.4.2](https://github.com/sarneeh/reaptcha/compare/v1.4.1...v1.4.2) (2019-01-18) 102 | 103 | **Note:** Version bump only for package reaptcha-example 104 | 105 | 106 | 107 | ## [1.4.1](https://github.com/sarneeh/reaptcha/compare/v1.4.0...v1.4.1) (2019-01-11) 108 | 109 | ### Bug Fixes 110 | 111 | - **package:** update styled-components to version 4.0.3 ([9bd381a](https://github.com/sarneeh/reaptcha/commit/9bd381a)) 112 | - **package:** update styled-components to version 4.1.0 ([f3fe8d9](https://github.com/sarneeh/reaptcha/commit/f3fe8d9)) 113 | - **package:** update styled-components to version 4.1.2 ([75e76aa](https://github.com/sarneeh/reaptcha/commit/75e76aa)) 114 | - **package:** update styled-components to version 4.1.3 ([8d5b72c](https://github.com/sarneeh/reaptcha/commit/8d5b72c)) 115 | 116 | 117 | 118 | # [1.4.0](https://github.com/sarneeh/reaptcha/compare/v1.3.0...v1.4.0) (2018-10-25) 119 | 120 | ### Bug Fixes 121 | 122 | - babel-loader version bump breaking build ([2ed8d81](https://github.com/sarneeh/reaptcha/commit/2ed8d81)) 123 | - using formik field outside of formik form crashes the app ([d91ca6e](https://github.com/sarneeh/reaptcha/commit/d91ca6e)), closes [#59](https://github.com/sarneeh/reaptcha/issues/59) 124 | 125 | 126 | 127 | # [1.3.0](https://github.com/sarneeh/reaptcha/compare/v1.2.1...v1.3.0) (2018-07-22) 128 | 129 | **Note:** Version bump only for package reaptcha-example 130 | 131 | 132 | 133 | ## [1.2.1](https://github.com/sarneeh/reaptcha/compare/v1.2.0...v1.2.1) (2018-07-04) 134 | 135 | **Note:** Version bump only for package reaptcha-example 136 | 137 | 138 | 139 | # [1.2.0](https://github.com/sarneeh/reaptcha/compare/v1.1.0...v1.2.0) (2018-07-04) 140 | 141 | **Note:** Version bump only for package reaptcha-example 142 | 143 | 144 | 145 | # [1.1.0](https://github.com/sarneeh/reaptcha/compare/v1.0.0...v1.1.0) (2018-06-13) 146 | 147 | **Note:** Version bump only for package reaptcha-example 148 | 149 | 150 | 151 | # [1.1.0-beta.1](https://github.com/sarneeh/reaptcha/compare/v1.0.0...v1.1.0-beta.1) (2018-06-13) 152 | 153 | **Note:** Version bump only for package reaptcha-example 154 | 155 | 156 | 157 | # [1.1.0-beta.0](https://github.com/sarneeh/reaptcha/compare/v1.0.0...v1.1.0-beta.0) (2018-06-13) 158 | 159 | **Note:** Version bump only for package reaptcha-example 160 | 161 | 162 | 163 | # [1.0.0](https://github.com/sarneeh/reaptcha/compare/v0.1.0-beta.1...v1.0.0) (2018-06-13) 164 | 165 | **Note:** Version bump only for package reaptcha-example 166 | 167 | 168 | 169 | # [0.1.0-beta.1](https://github.com/sarneeh/reaptcha/compare/v0.1.0-beta.0...v0.1.0-beta.1) (2018-06-13) 170 | 171 | ### Bug Fixes 172 | 173 | - **example:** reset submitted flag on expire ([76936ff](https://github.com/sarneeh/reaptcha/commit/76936ff)) 174 | - config flow type in example ([c5fe42e](https://github.com/sarneeh/reaptcha/commit/c5fe42e)) 175 | - proper flow types ([36beb16](https://github.com/sarneeh/reaptcha/commit/36beb16)) 176 | 177 | ### Features 178 | 179 | - **example:** add invisible size configuration option ([3ba6841](https://github.com/sarneeh/reaptcha/commit/3ba6841)) 180 | - **example:** add render method configuration option ([88302c7](https://github.com/sarneeh/reaptcha/commit/88302c7)) 181 | - **example:** create one example component for both render methods ([e434ecb](https://github.com/sarneeh/reaptcha/commit/e434ecb)) 182 | - example as a form ([7dae749](https://github.com/sarneeh/reaptcha/commit/7dae749)) 183 | - **example:** footer ([77d8d12](https://github.com/sarneeh/reaptcha/commit/77d8d12)) 184 | - **example:** fully functional and covered example ([38d5496](https://github.com/sarneeh/reaptcha/commit/38d5496)) 185 | - **example:** handle render param in example component ([3f50a42](https://github.com/sarneeh/reaptcha/commit/3f50a42)) 186 | - **example:** recaptcha status indicators ([a7f9ddc](https://github.com/sarneeh/reaptcha/commit/a7f9ddc)) 187 | - **example:** remove react-router, make app single-routed ([acbae8a](https://github.com/sarneeh/reaptcha/commit/acbae8a)) 188 | - **example:** reset button ([be44f80](https://github.com/sarneeh/reaptcha/commit/be44f80)) 189 | 190 | 191 | 192 | # 0.1.0-beta.0 (2018-06-12) 193 | 194 | **Note:** Version bump only for package reaptcha-example 195 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "reaptcha-example", 3 | "version": "1.12.0", 4 | "private": true, 5 | "scripts": { 6 | "build": "webpack --config webpack.prod.js", 7 | "dev": "webpack-dev-server --config webpack.dev.js" 8 | }, 9 | "dependencies": { 10 | "formik": "^1.1.1", 11 | "react": "^18.1.0", 12 | "react-dom": "^18.1.0", 13 | "reaptcha": "^1.12.1", 14 | "styled-components": "^5.3.5" 15 | }, 16 | "devDependencies": { 17 | "@types/styled-components": "^5.1.24", 18 | "clean-webpack-plugin": "^4.0.0", 19 | "html-webpack-plugin": "^5.5.0", 20 | "ts-loader": "^9.2.7", 21 | "webpack": "^5.69.1", 22 | "webpack-cli": "^4.9.2", 23 | "webpack-dev-server": "^4.7.4", 24 | "webpack-merge": "^5.8.0" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /example/src/components/button.tsx: -------------------------------------------------------------------------------- 1 | import styled, { css } from 'styled-components'; 2 | 3 | export type Props = { 4 | ml?: boolean; 5 | mr?: boolean; 6 | executing?: boolean; 7 | submitted?: boolean; 8 | white?: boolean; 9 | short?: boolean; 10 | small?: boolean; 11 | }; 12 | 13 | const whiteButton = css` 14 | color: ${props => props.theme.black}; 15 | background-color: ${props => props.theme.white}; 16 | box-shadow: 0 3px 0 0 ${props => props.theme.darkwhite}; 17 | 18 | &:hover:enabled { 19 | cursor: pointer; 20 | background-color: ${props => props.theme.darkwhite}; 21 | box-shadow: 0 3px 0 0 ${props => props.theme.darkestwhite}; 22 | } 23 | `; 24 | 25 | const blueButton = css` 26 | color: ${props => props.theme.white}; 27 | background-color: ${props => props.theme.blue}; 28 | box-shadow: 0 3px 0 0 ${props => props.theme.darkblue}; 29 | 30 | &:hover:enabled { 31 | cursor: pointer; 32 | background-color: ${props => props.theme.darkblue}; 33 | box-shadow: 0 3px 0 0 ${props => props.theme.darkestblue}; 34 | } 35 | `; 36 | 37 | const greenButton = css` 38 | color: ${props => props.theme.white}; 39 | background-color: ${props => props.theme.green}; 40 | box-shadow: 0 3px 0 0 ${props => props.theme.darkgreen}; 41 | 42 | &:hover:enabled { 43 | cursor: pointer; 44 | background-color: ${props => props.theme.darkgreen}; 45 | box-shadow: 0 3px 0 0 ${props => props.theme.darkestgreen}; 46 | } 47 | `; 48 | 49 | const orangeButton = css` 50 | color: ${props => props.theme.white}; 51 | background-color: ${props => props.theme.orange}; 52 | box-shadow: 0 3px 0 0 ${props => props.theme.darkorange}; 53 | 54 | &:hover:enabled { 55 | cursor: pointer; 56 | background-color: ${props => props.theme.darkorange}; 57 | box-shadow: 0 3px 0 0 ${props => props.theme.darkestorange}; 58 | } 59 | `; 60 | 61 | const ml = css` 62 | margin-left: ${props => props.theme.smallmargin}; 63 | `; 64 | 65 | const mr = css` 66 | margin-right: ${props => props.theme.smallmargin}; 67 | `; 68 | 69 | const initialState = css` 70 | cursor: default; 71 | 72 | ${props => (props.ml ? ml : null)}; 73 | ${props => (props.mr ? mr : null)}; 74 | 75 | ${props => { 76 | if (props.executing) return orangeButton; 77 | if (props.submitted) return greenButton; 78 | if (props.white) return whiteButton; 79 | return blueButton; 80 | }}; 81 | 82 | a { 83 | color: inherit; 84 | 85 | &:hover { 86 | color: inherit; 87 | } 88 | } 89 | `; 90 | 91 | export default styled.button` 92 | ${initialState}; 93 | 94 | outline: none; 95 | white-space: nowrap; 96 | border: none; 97 | border-radius: 4px; 98 | padding: ${props => (props.short ? '.3rem' : props.small ? '.5rem' : '1rem')}; 99 | font-size: ${props => (props.small || props.small ? '.7rem' : '1rem')}; 100 | margin-bottom: 3px; 101 | 102 | &:disabled { 103 | opacity: 0.5; 104 | } 105 | `; 106 | -------------------------------------------------------------------------------- /example/src/components/container.tsx: -------------------------------------------------------------------------------- 1 | import styled, { css } from 'styled-components'; 2 | 3 | export type Props = { 4 | mb?: boolean; 5 | page?: boolean; 6 | flex?: boolean; 7 | inline?: boolean; 8 | between?: boolean; 9 | gap?: boolean; 10 | }; 11 | 12 | const mb = css` 13 | margin-bottom: ${props => props.theme.margin}; 14 | `; 15 | 16 | const page = css` 17 | max-width: 400px; 18 | margin: 0 auto; 19 | 20 | @media (max-width: 600px) { 21 | margin: 0 ${props => props.theme.margin}; 22 | } 23 | `; 24 | 25 | const flex = css` 26 | display: flex; 27 | 28 | @media (max-width: 600px) { 29 | flex-direction: column; 30 | } 31 | `; 32 | 33 | const inline = css` 34 | display: flex; 35 | align-items: center; 36 | `; 37 | 38 | const between = css` 39 | display: flex; 40 | align-items: center; 41 | justify-content: space-between; 42 | `; 43 | 44 | const gap = css` 45 | gap: ${props => props.theme.smallmargin}; 46 | `; 47 | 48 | export default styled.div` 49 | ${props => (props.mb ? mb : null)}; 50 | ${props => (props.page ? page : null)}; 51 | ${props => (props.flex ? flex : null)}; 52 | ${props => (props.inline ? inline : null)}; 53 | ${props => (props.between ? between : null)}; 54 | ${props => (props.gap ? gap : null)}; 55 | `; 56 | -------------------------------------------------------------------------------- /example/src/components/form.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const FormGroup = styled.div` 4 | margin-right: 5rem; 5 | 6 | @media (max-width: 600px) { 7 | margin-right: 0; 8 | margin-bottom: 1rem; 9 | } 10 | `; 11 | -------------------------------------------------------------------------------- /example/src/components/header.tsx: -------------------------------------------------------------------------------- 1 | import styled, { css } from 'styled-components'; 2 | 3 | export type Props = { 4 | mb?: boolean; 5 | mbs?: boolean; 6 | inline?: boolean; 7 | }; 8 | 9 | const mb = css` 10 | margin-bottom: ${props => props.theme.margin}; 11 | `; 12 | 13 | const mbs = css` 14 | margin-bottom: ${props => props.theme.smallmargin}; 15 | `; 16 | 17 | const inline = css` 18 | display: inline-block; 19 | `; 20 | 21 | const header = css` 22 | margin: 0; 23 | font-weight: 400; 24 | 25 | ${props => (props.mb ? mb : null)}; 26 | ${props => (props.mbs ? mbs : null)}; 27 | ${props => (props.inline ? inline : null)}; 28 | `; 29 | 30 | export const H1 = styled.h1` 31 | ${header}; 32 | font-size: 2rem; 33 | `; 34 | 35 | export const H2 = styled.h2` 36 | ${header}; 37 | font-size: 1.2rem; 38 | `; 39 | 40 | export const H3 = styled.h3` 41 | ${header}; 42 | font-size: 1rem; 43 | `; 44 | -------------------------------------------------------------------------------- /example/src/components/link.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export type Props = { 4 | black?: boolean; 5 | }; 6 | 7 | export default styled.a` 8 | text-decoration: none; 9 | color: ${props => (props.black ? props.theme.black : props.theme.white)}; 10 | 11 | &:hover { 12 | color: ${props => props.theme.darkestwhite}; 13 | } 14 | `; 15 | -------------------------------------------------------------------------------- /example/src/components/radio.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import { Field } from 'formik'; 4 | 5 | import Container from './container'; 6 | 7 | const Radio = styled.input.attrs({ 8 | type: 'radio' 9 | })` 10 | margin: 0; 11 | 12 | + label { 13 | margin-left: 0.5rem; 14 | } 15 | `; 16 | 17 | type Props = { 18 | id: string; 19 | name: string; 20 | label: string; 21 | value: string; 22 | }; 23 | 24 | const RadioInput = ({ id, name, label, value }: Props) => ( 25 | 26 | ( 29 | 36 | )} 37 | /> 38 | 39 | 40 | ); 41 | 42 | export default RadioInput; 43 | -------------------------------------------------------------------------------- /example/src/components/section.tsx: -------------------------------------------------------------------------------- 1 | import styled, { css } from 'styled-components'; 2 | 3 | export type Props = { 4 | blue?: boolean; 5 | gray?: boolean; 6 | }; 7 | 8 | const blue = css` 9 | background-color: ${props => props.theme.blue}; 10 | color: ${props => props.theme.white}; 11 | `; 12 | 13 | const gray = css` 14 | background-color: ${props => props.theme.gray}; 15 | color: ${props => props.theme.black}; 16 | `; 17 | 18 | export default styled.div` 19 | padding: 2rem 0; 20 | width: 100%; 21 | 22 | ${props => (props.blue ? blue : null)}; 23 | ${props => (props.gray ? gray : null)}; 24 | 25 | &:first-of-type { 26 | margin-top: 0; 27 | } 28 | `; 29 | -------------------------------------------------------------------------------- /example/src/components/status.tsx: -------------------------------------------------------------------------------- 1 | import styled, { css } from 'styled-components'; 2 | 3 | export type Props = { 4 | active?: boolean; 5 | }; 6 | 7 | const redBadge = css` 8 | background-color: ${props => props.theme.red}; 9 | `; 10 | 11 | const greenBadge = css` 12 | background-color: ${props => props.theme.green}; 13 | `; 14 | 15 | export default styled.div` 16 | border-radius: 4px; 17 | margin-right: 0.5rem; 18 | font-size: 0.7rem; 19 | padding: 0.25rem; 20 | color: ${props => props.theme.white}; 21 | 22 | &:first-of-type { 23 | margin-left: 0.5rem; 24 | } 25 | 26 | &:last-of-type { 27 | margin-right: 0; 28 | } 29 | 30 | ${props => { 31 | if (props.active) return greenBadge; 32 | return redBadge; 33 | }}; 34 | `; 35 | -------------------------------------------------------------------------------- /example/src/config/index.ts: -------------------------------------------------------------------------------- 1 | const NORMAL_SITE_KEY = '6LcIEVwUAAAAAEnR50W15N0XjSGG8vOTVgVCfqU6'; 2 | const INVISIBLE_SITE_KEY = '6LesI14UAAAAAFwOYJfOm84jpq8Wzlb9T4HQDOtS'; 3 | 4 | export const getSiteKey = (invisible: boolean) => 5 | invisible ? INVISIBLE_SITE_KEY : NORMAL_SITE_KEY; 6 | 7 | export const theme = { 8 | blue: '#4683F3', 9 | darkblue: '#3060D1', 10 | darkestblue: '#2F5AC9', 11 | green: '#3BB273', 12 | darkgreen: '#36A269', 13 | orange: '#E1BC29', 14 | darkorange: '#CDAB26', 15 | white: '#ffffff', 16 | darkwhite: '#e5e5e5', 17 | darkestwhite: '#cccccc', 18 | black: '#000000', 19 | gray: '#f5f5f5', 20 | red: '#E15554', 21 | 22 | margin: '1rem', 23 | smallmargin: '.5rem' 24 | }; 25 | -------------------------------------------------------------------------------- /example/src/containers/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component, Fragment } from 'react'; 2 | import { ThemeProvider, createGlobalStyle } from 'styled-components'; 3 | 4 | import { theme } from '../config'; 5 | 6 | import Container from '../components/container'; 7 | import Section from '../components/section'; 8 | import { H1 } from '../components/header'; 9 | import Button from '../components/button'; 10 | import Link from '../components/link'; 11 | 12 | import Options from '../modules/options'; 13 | import Example, { Config } from '../modules/example'; 14 | 15 | const GlobalStyle = createGlobalStyle` 16 | body { 17 | margin: 0; 18 | font-family: 'Open Sans', sans-serif; 19 | } 20 | `; 21 | 22 | type State = { 23 | config: Config; 24 | }; 25 | 26 | class App extends Component, State> { 27 | state: State = { 28 | config: { 29 | size: 'normal', 30 | theme: 'light', 31 | render: 'automatic' 32 | } 33 | }; 34 | 35 | onChange = (configChange: Partial) => 36 | this.setState(state => ({ 37 | config: { 38 | ...state.config, 39 | ...configChange 40 | } 41 | })); 42 | 43 | render() { 44 | return ( 45 | 46 | 47 | 48 |
49 | 50 | 51 |

Reaptcha

52 | 57 |
58 |
reCAPTCHA for React.
59 |
60 |
61 |
62 | 63 | 64 | 65 |
66 |
67 | 68 | 69 | 70 |
71 |
72 | 73 | Created by{' '} 74 | 75 | Jakub Sarnowski 76 | 77 | . 78 | 79 |
80 |
81 |
82 | ); 83 | } 84 | } 85 | 86 | export default App; 87 | -------------------------------------------------------------------------------- /example/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | reCATPCHA for React 5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 | 13 | -------------------------------------------------------------------------------- /example/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { createRoot } from 'react-dom/client'; 3 | import App from './containers'; 4 | 5 | const app = document.getElementById('app'); 6 | if (!app) { 7 | throw new Error('No app element found'); 8 | } 9 | 10 | const root = createRoot(app); 11 | root.render( 12 | 13 | 14 | 15 | ); 16 | -------------------------------------------------------------------------------- /example/src/modules/example.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | Fragment, 3 | SyntheticEvent, 4 | useEffect, 5 | useRef, 6 | useReducer, 7 | useState 8 | } from 'react'; 9 | import Reaptcha, { Props as ReaptchaProps } from 'reaptcha'; 10 | 11 | import { getSiteKey } from '../config'; 12 | 13 | import Button from '../components/button'; 14 | import Container from '../components/container'; 15 | import Status from '../components/status'; 16 | import { H2 } from '../components/header'; 17 | 18 | export type Config = Pick & { 19 | render: 'explicit' | 'automatic'; 20 | }; 21 | 22 | type Props = { 23 | config: Config; 24 | }; 25 | 26 | type State = { 27 | token: string; 28 | loaded: boolean; 29 | rendered: boolean; 30 | verified: boolean; 31 | executed: boolean; 32 | executing: boolean; 33 | }; 34 | 35 | const initialState = { 36 | token: '', 37 | loaded: false, 38 | rendered: false, 39 | verified: false, 40 | executed: false, 41 | executing: false 42 | }; 43 | 44 | type Action = { type: 'RESET' } | { type: 'UPDATE'; value: Partial }; 45 | 46 | const exampleReducer = (state: State, action: Action): State => { 47 | switch (action.type) { 48 | case 'RESET': 49 | return { ...initialState, loaded: true }; 50 | case 'UPDATE': 51 | return { ...state, ...action.value }; 52 | default: 53 | return state; 54 | } 55 | }; 56 | 57 | const Example = ({ config }: Props) => { 58 | const captcha = useRef(null); 59 | const [state, dispatch] = useReducer(exampleReducer, initialState); 60 | const [name, setName] = useState(''); 61 | 62 | const onLoad = () => { 63 | dispatch({ type: 'UPDATE', value: { loaded: true } }); 64 | }; 65 | 66 | const onRender = () => { 67 | dispatch({ type: 'UPDATE', value: { rendered: true } }); 68 | }; 69 | 70 | const onVerify = (invisible: boolean) => (token: string) => { 71 | dispatch({ 72 | type: 'UPDATE', 73 | value: { 74 | token, 75 | verified: true, 76 | executed: true 77 | } 78 | }); 79 | 80 | if (invisible) { 81 | dispatch({ 82 | type: 'UPDATE', 83 | value: { 84 | executing: false, 85 | executed: true 86 | } 87 | }); 88 | } 89 | }; 90 | 91 | const onExpire = (invisible: boolean) => () => { 92 | dispatch({ type: 'UPDATE', value: { verified: false } }); 93 | 94 | if (invisible) { 95 | dispatch({ type: 'UPDATE', value: { executed: true } }); 96 | } 97 | }; 98 | 99 | const renderRecaptcha = () => { 100 | captcha.current?.renderExplicitly(); 101 | dispatch({ type: 'UPDATE', value: { rendered: true } }); 102 | }; 103 | 104 | const executeRecaptcha = () => { 105 | dispatch({ type: 'UPDATE', value: { executing: true } }); 106 | captcha.current?.execute(); 107 | }; 108 | 109 | const resetRecaptcha = () => { 110 | captcha.current?.reset(); 111 | dispatch({ 112 | type: 'UPDATE', 113 | value: { 114 | verified: false, 115 | executed: false 116 | } 117 | }); 118 | }; 119 | 120 | const getResponseRecaptcha = () => { 121 | captcha.current?.getResponse().then(response => { 122 | alert(response); 123 | }); 124 | }; 125 | 126 | const submitForm = (invisible: boolean) => ( 127 | e: SyntheticEvent 128 | ) => { 129 | e.preventDefault(); 130 | if (invisible) { 131 | executeRecaptcha(); 132 | } else { 133 | dispatch({ type: 'UPDATE', value: { executed: false } }); 134 | } 135 | }; 136 | 137 | useEffect(() => { 138 | if (config.render === 'explicit') { 139 | dispatch({ type: 'RESET' }); 140 | } 141 | }, [config.render, config.size, config.theme]); 142 | 143 | const { render, size, theme } = config; 144 | const explicit = render === 'explicit'; 145 | const invisible = size === 'invisible'; 146 | const sitekey = getSiteKey(invisible); 147 | 148 | return ( 149 | 150 |

Example

151 | setName(e.target.value)} /> 152 | 153 |
reCAPTCHA status:
154 | 155 | Loaded 156 | Rendered 157 | Verified 158 | 159 |
160 |
161 | 162 | 174 | 175 | 176 | {explicit && ( 177 | 184 | )} 185 | {invisible && ( 186 | 203 | )} 204 | 211 | 218 | 219 |
220 |
221 | ); 222 | }; 223 | 224 | export default Example; 225 | -------------------------------------------------------------------------------- /example/src/modules/options.tsx: -------------------------------------------------------------------------------- 1 | import React, { Fragment } from 'react'; 2 | import { Formik, Form } from 'formik'; 3 | 4 | import Container from '../components/container'; 5 | import Radio from '../components/radio'; 6 | import { FormGroup } from '../components/form'; 7 | import { H2, H3 } from '../components/header'; 8 | 9 | const initialConfig = { 10 | theme: 'light', 11 | size: 'normal', 12 | render: 'automatic' 13 | }; 14 | 15 | type Props = { 16 | // eslint-disable-next-line @typescript-eslint/ban-types 17 | onChange: Function; 18 | }; 19 | 20 | const Options = (props: Props) => ( 21 | props.onChange(values)} 23 | initialValues={initialConfig} 24 | render={() => ( 25 | 26 |

Configuration

27 |
{ 29 | const target = e.target as HTMLInputElement; 30 | props.onChange({ [target.name]: target.value }); 31 | }} 32 | > 33 | 34 | 35 |

Theme

36 | 37 | 38 |
39 | 40 |

Size

41 | 42 | 43 | 49 |
50 | 51 |

Render

52 | 58 | 64 |
65 |
66 |
67 |
68 | )} 69 | /> 70 | ); 71 | 72 | export default Options; 73 | -------------------------------------------------------------------------------- /example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.common.json" 3 | } 4 | -------------------------------------------------------------------------------- /example/webpack.common.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 3 | 4 | module.exports = { 5 | entry: path.resolve(__dirname, 'src/index.tsx'), 6 | output: { 7 | filename: 'index.js', 8 | path: path.resolve(__dirname, 'dist'), 9 | publicPath: '/reaptcha/' 10 | }, 11 | resolve: { 12 | extensions: ['.tsx', '.ts', '.js'] 13 | }, 14 | module: { 15 | rules: [ 16 | { 17 | test: /\.tsx?$/, 18 | use: 'ts-loader', 19 | exclude: /node_modules/ 20 | } 21 | ] 22 | }, 23 | plugins: [ 24 | new HtmlWebpackPlugin({ 25 | template: 'src/index.html' 26 | }) 27 | ] 28 | }; 29 | -------------------------------------------------------------------------------- /example/webpack.dev.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack'); 2 | const { merge } = require('webpack-merge'); 3 | 4 | const common = require('./webpack.common'); 5 | 6 | module.exports = merge(common, { 7 | mode: 'development', 8 | devServer: { 9 | hot: true, 10 | static: { 11 | directory: __dirname, 12 | publicPath: common.output.publicPath 13 | }, 14 | historyApiFallback: { 15 | index: common.output.publicPath 16 | } 17 | }, 18 | optimization: { 19 | moduleIds: 'named' 20 | }, 21 | plugins: [new webpack.HotModuleReplacementPlugin()] 22 | }); 23 | -------------------------------------------------------------------------------- /example/webpack.prod.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack'); 2 | const { merge } = require('webpack-merge'); 3 | const { CleanWebpackPlugin } = require('clean-webpack-plugin'); 4 | 5 | const common = require('./webpack.common'); 6 | 7 | module.exports = merge(common, { 8 | mode: 'production', 9 | output: { 10 | filename: 'index.[chunkhash].js' 11 | }, 12 | plugins: [ 13 | new CleanWebpackPlugin(), 14 | new webpack.DefinePlugin({ 15 | 'process.env.NODE_ENV': JSON.stringify('production') 16 | }) 17 | ] 18 | }); 19 | -------------------------------------------------------------------------------- /lib/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | ## 1.12.1 4 | 5 | ### Patch Changes 6 | 7 | - 251fb14: support StrictMode in react@18 8 | 9 | ## 1.12.0 10 | 11 | ### Minor Changes 12 | 13 | - 2e1033b: support react@^18 14 | 15 | ## 1.11.0 16 | 17 | ### Minor Changes 18 | 19 | - 9be85f7: fix a problem where local functional state could not be used inside `Reaptcha` callback methods 20 | 21 | ## 1.10.1 22 | 23 | ### Patch Changes 24 | 25 | - abe6170: fix uncaught (in promise) timeout error 26 | 27 | ## 1.10.0 28 | 29 | ### Minor Changes 30 | 31 | - cb8d8b0: allow for dynamic config changes (after recaptcha has been already rendered) 32 | 33 | ## 1.9.0 34 | 35 | ### Minor Changes 36 | 37 | - 58afe5c: Migrate to TypeScript 38 | 39 | ## 1.8.0 40 | 41 | ### Minor Changes 42 | 43 | - 8163760: Improved bundle, removed redundant dependencies 44 | 45 | ### Patch Changes 46 | 47 | - 03bf4ff: Migrate from lerna to turborepo + changeset 48 | 49 | All notable changes to this project will be documented in this file. 50 | See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. 51 | 52 | ## [1.7.3](https://github.com/sarneeh/reaptcha/compare/v1.7.2...v1.7.3) (2021-09-03) 53 | 54 | **Note:** Version bump only for package reaptcha 55 | 56 | 57 | 58 | ## [1.7.2](https://github.com/sarneeh/reaptcha/compare/v1.7.1...v1.7.2) (2019-12-28) 59 | 60 | ### Bug Fixes 61 | 62 | - update component on className change ([3a376d4](https://github.com/sarneeh/reaptcha/commit/3a376d4)), closes [#189](https://github.com/sarneeh/reaptcha/issues/189) 63 | 64 | 65 | 66 | ## [1.7.1](https://github.com/sarneeh/reaptcha/compare/v1.7.0...v1.7.1) (2019-12-28) 67 | 68 | ### Bug Fixes 69 | 70 | - add getResponse method ([ab57db7](https://github.com/sarneeh/reaptcha/commit/ab57db7)) 71 | 72 | 73 | 74 | # [1.7.0](https://github.com/sarneeh/reaptcha/compare/v1.6.0...v1.7.0) (2019-10-20) 75 | 76 | ### Bug Fixes 77 | 78 | - add missing render prop ([2137d81](https://github.com/sarneeh/reaptcha/commit/2137d81)) 79 | - cant call this.props.children cause getResponse missing ([77ee190](https://github.com/sarneeh/reaptcha/commit/77ee190)) 80 | - resolve and reject not defined ([eac3a13](https://github.com/sarneeh/reaptcha/commit/eac3a13)) 81 | 82 | ### Features 83 | 84 | - added getResponse to types ([33d6d16](https://github.com/sarneeh/reaptcha/commit/33d6d16)) 85 | - exposed getResponse ([c0793aa](https://github.com/sarneeh/reaptcha/commit/c0793aa)) 86 | 87 | 88 | 89 | ## [1.5.1](https://github.com/sarneeh/reaptcha/compare/v1.5.0...v1.5.1) (2019-06-28) 90 | 91 | **Note:** Version bump only for package reaptcha 92 | 93 | 94 | 95 | # [1.5.0](https://github.com/sarneeh/reaptcha/compare/v1.4.2...v1.5.0) (2019-05-25) 96 | 97 | ### Bug Fixes 98 | 99 | - **theme:** support invisible dark mode ([44c6c82](https://github.com/sarneeh/reaptcha/commit/44c6c82)) 100 | 101 | 102 | 103 | ## [1.4.2](https://github.com/sarneeh/reaptcha/compare/v1.4.1...v1.4.2) (2019-01-18) 104 | 105 | ### Bug Fixes 106 | 107 | - script detection ([5634d35](https://github.com/sarneeh/reaptcha/commit/5634d35)), closes [#18](https://github.com/sarneeh/reaptcha/issues/18) 108 | 109 | 110 | 111 | ## [1.4.1](https://github.com/sarneeh/reaptcha/compare/v1.4.0...v1.4.1) (2019-01-11) 112 | 113 | **Note:** Version bump only for package reaptcha 114 | 115 | 116 | 117 | # [1.4.0](https://github.com/sarneeh/reaptcha/compare/v1.3.0...v1.4.0) (2018-10-25) 118 | 119 | ### Bug Fixes 120 | 121 | - ava tests on babel 7 ([4370fc6](https://github.com/sarneeh/reaptcha/commit/4370fc6)) 122 | - babel-loader version bump breaking build ([2ed8d81](https://github.com/sarneeh/reaptcha/commit/2ed8d81)) 123 | - better test when hl parameter is not specified ([eed56be](https://github.com/sarneeh/reaptcha/commit/eed56be)) 124 | - if hl param is specified, inject it in script ([2ad96a8](https://github.com/sarneeh/reaptcha/commit/2ad96a8)) 125 | - use props hl to init script to set up the correct language ([ebeb734](https://github.com/sarneeh/reaptcha/commit/ebeb734)) 126 | 127 | 128 | 129 | # [1.3.0](https://github.com/sarneeh/reaptcha/compare/v1.2.1...v1.3.0) (2018-07-22) 130 | 131 | ### Bug Fixes 132 | 133 | - cleanup on unmount, removed deprecated react methods ([7c30ba4](https://github.com/sarneeh/reaptcha/commit/7c30ba4)), closes [#29](https://github.com/sarneeh/reaptcha/issues/29) [#28](https://github.com/sarneeh/reaptcha/issues/28) [#26](https://github.com/sarneeh/reaptcha/issues/26) 134 | - do not inject script when one already present ([5db105d](https://github.com/sarneeh/reaptcha/commit/5db105d)), closes [#18](https://github.com/sarneeh/reaptcha/issues/18) 135 | 136 | 137 | 138 | ## [1.2.1](https://github.com/sarneeh/reaptcha/compare/v1.2.0...v1.2.1) (2018-07-04) 139 | 140 | ### Bug Fixes 141 | 142 | - default container classname ([d2071fa](https://github.com/sarneeh/reaptcha/commit/d2071fa)) 143 | 144 | 145 | 146 | # [1.2.0](https://github.com/sarneeh/reaptcha/compare/v1.1.0...v1.2.0) (2018-07-04) 147 | 148 | ### Features 149 | 150 | - **render:** language support with `hl` ([7bd5e90](https://github.com/sarneeh/reaptcha/commit/7bd5e90)) 151 | 152 | 153 | 154 | # [1.1.0](https://github.com/sarneeh/reaptcha/compare/v1.0.0...v1.1.0) (2018-06-13) 155 | 156 | ### Features 157 | 158 | - make package usable in script tags ([c5ac515](https://github.com/sarneeh/reaptcha/commit/c5ac515)) 159 | 160 | 161 | 162 | # [1.1.0-beta.1](https://github.com/sarneeh/reaptcha/compare/v1.0.0...v1.1.0-beta.1) (2018-06-13) 163 | 164 | ### Features 165 | 166 | - make package usable in script tags ([ac0733a](https://github.com/sarneeh/reaptcha/commit/ac0733a)) 167 | 168 | 169 | 170 | # [1.1.0-beta.0](https://github.com/sarneeh/reaptcha/compare/v1.0.0...v1.1.0-beta.0) (2018-06-13) 171 | 172 | ### Features 173 | 174 | - make package usable in script tags ([ac0733a](https://github.com/sarneeh/reaptcha/commit/ac0733a)) 175 | 176 | 177 | 178 | # [1.0.0](https://github.com/sarneeh/reaptcha/compare/v0.1.0-beta.1...v1.0.0) (2018-06-13) 179 | 180 | **Note:** Version bump only for package reaptcha 181 | 182 | 183 | 184 | # [0.1.0-beta.1](https://github.com/sarneeh/reaptcha/compare/v0.1.0-beta.0...v0.1.0-beta.1) (2018-06-13) 185 | 186 | ### Bug Fixes 187 | 188 | - don't reset on execution ([8047e1e](https://github.com/sarneeh/reaptcha/commit/8047e1e)) 189 | - reset recaptcha before execute ([e75a355](https://github.com/sarneeh/reaptcha/commit/e75a355)) 190 | 191 | 192 | 193 | # 0.1.0-beta.0 (2018-06-12) 194 | 195 | **Note:** Version bump only for package reaptcha 196 | -------------------------------------------------------------------------------- /lib/README.md: -------------------------------------------------------------------------------- 1 | ![Reaptcha](https://i.imgur.com/44zEjD5.png) 2 | 3 | [![Latest npm version](https://img.shields.io/npm/v/reaptcha/latest.svg)](https://www.npmjs.com/package/reaptcha) 4 | [![GitHub license](https://img.shields.io/github/license/sarneeh/reaptcha.svg)](https://github.com/sarneeh/reaptcha) 5 | [![GitHub Workflow Status](https://img.shields.io/github/workflow/status/sarneeh/reaptcha/push)](https://github.com/sarneeh/reaptcha/actions/workflows/push.yml) 6 | [![Codecov](https://img.shields.io/codecov/c/github/sarneeh/reaptcha?token=b3FQAJsTGL)](https://codecov.io/gh/sarneeh/reaptcha) 7 | [![Conventional Commits](https://img.shields.io/badge/Conventional%20Commits-1.0.0-yellow.svg)](https://conventionalcommits.org) 8 | [![Commitizen friendly](https://img.shields.io/badge/commitizen-friendly-brightgreen.svg)](http://commitizen.github.io/cz-cli/) 9 | [![Minified package size](https://img.shields.io/bundlephobia/min/reaptcha.svg)]() 10 | [![Minified gzipped package size](https://img.shields.io/bundlephobia/minzip/reaptcha.svg)]() 11 | 12 | A clean, modern and simple React wrapper for [Google reCAPTCHA](https://developers.google.com/recaptcha/). 13 | 14 | ## Demo 15 | 16 | https://sarneeh.github.io/reaptcha/ 17 | 18 | ## Motivation 19 | 20 | I've been using other React wrappers for reCAPTCHA like [react-recaptcha](https://github.com/appleboy/react-recaptcha) 21 | or [react-google-recaptcha](https://github.com/dozoisch/react-google-recaptcha) but unfortunately both of them provide a 22 | non-react way (declaring the callbacks outside React components, not inside them) to handle all the reCAPTCHA callbacks 23 | which didn't feel clean and I didn't like this approach personally. 24 | 25 | This is why I've decided to give it a try to create a cleaner approach and this is the result. 26 | 27 | ## Features 28 | 29 | - **All callbacks in your React component** 30 | - 100% test coverage 31 | - Automatic reCAPTCHA script injection and cleanup 32 | - Usable with multiple reCAPTCHA instances 33 | - Full control over every reCAPTCHA instance 34 | - reCAPTCHA instance methods with promises and clean error messages 35 | - SSR ready 36 | 37 | ## Installation 38 | 39 | Just install the package with `npm`: 40 | 41 | ``` 42 | npm install --save reaptcha 43 | ``` 44 | 45 | or with `yarn`: 46 | 47 | ``` 48 | yarn add reaptcha 49 | ``` 50 | 51 | ## Usage 52 | 53 | **IMPORTANT NOTE: `Reaptcha` injects reCAPTCHA script into DOM automatically by default. If you are doing it 54 | manually, [check out this description](#attaching-recaptcha-script-manually).** 55 | 56 | First of all, you'll need a reCAPTCHA API key. To find out how to get it 57 | 58 | - [check this guide](https://developers.google.com/recaptcha/intro). 59 | 60 | To see how `Reaptcha` actually works, visit the [example page](https://sarneeh.github.io/reaptcha). 61 | 62 | If you'd also like to see the code for the example, it is 63 | right [here](https://github.com/sarneeh/reaptcha/tree/master/example/src). 64 | 65 | ### Default - Automatic render 66 | 67 | By default `Reaptcha` injects the reCAPTCHA script into your `head` DOM element and renders a `I'm a robot` captcha. 68 | 69 | Here's a quick example how can you use `Reaptcha` to verify your form submission: 70 | 71 | ```jsx 72 | import React, { Component } from 'react'; 73 | import Reaptcha from 'reaptcha'; 74 | 75 | class MyForm extends Component { 76 | constructor(props) { 77 | super(props); 78 | this.state = { 79 | verified: false 80 | }; 81 | } 82 | 83 | onVerify = recaptchaResponse => { 84 | this.setState({ 85 | verified: true 86 | }); 87 | }; 88 | 89 | render() { 90 | return ( 91 |
92 | 93 | 96 | 97 | ); 98 | } 99 | } 100 | ``` 101 | 102 | ### Explicit render 103 | 104 | In order to render `Reaptcha` explicitly, you need to pass the `explicit` prop, store the reference to the instance and 105 | call the `renderExplicitly` function manually. 106 | 107 | **Caution!** In order to prevent race-conditions, make sure you render the reCAPTCHA after the script successfuly loads. 108 | To do that, pass a function the the `onLoad` prop where you'll get informed that everything is ready to render. 109 | 110 | Here's an example: 111 | 112 | ```jsx 113 | import React, { Fragment, Component } from 'react'; 114 | import Reaptcha from 'reaptcha'; 115 | 116 | class MyForm extends Component { 117 | constructor(props: Props) { 118 | super(props); 119 | this.captcha = null; 120 | this.state = { 121 | captchaReady: false 122 | }; 123 | } 124 | 125 | onLoad = () => { 126 | this.setState({ 127 | captchaReady: true 128 | }); 129 | }; 130 | 131 | onVerify = recaptchaResponse => { 132 | // Do something 133 | }; 134 | 135 | render() { 136 | return ( 137 | 138 | (this.captcha = e)} 140 | sitekey="YOUR_API_KEY" 141 | onLoad={this.onLoad} 142 | onVerify={this.onVerify} 143 | explicit 144 | /> 145 | 153 | 154 | ); 155 | } 156 | } 157 | ``` 158 | 159 | ### Invisible 160 | 161 | When you want to have an invisible reCAPTCHA, you'll have to `execute` it manually (as user won't have any possibility 162 | to do it). This can be done similarly to explicit rendering - saving the reference to the `Reaptcha` instance and call 163 | the `execute` method on it. 164 | 165 | Additionally, invisible reCAPTCHA can be of course also rendered automatically or explicitly - this is your choice and 166 | the reference how to do it is right above. 167 | 168 | ```jsx 169 | import React, { Fragment, Component } from 'react'; 170 | import Reaptcha from 'reaptcha'; 171 | 172 | class MyForm extends Component { 173 | constructor(props: Props) { 174 | super(props); 175 | this.captcha = null; 176 | } 177 | 178 | onVerify = recaptchaResponse => { 179 | // Do something 180 | }; 181 | 182 | render() { 183 | return ( 184 | 185 | (this.captcha = e)} 187 | sitekey="YOUR_API_KEY" 188 | onVerify={this.onVerify} 189 | size="invisible" 190 | /> 191 | 198 | 199 | ); 200 | } 201 | } 202 | ``` 203 | 204 | ### Reset 205 | 206 | You can also manually reset your reCAPTCHA instance. It's similar to executing it: 207 | 208 | ```jsx 209 | 210 | (this.captcha = e)} 212 | sitekey="YOUR_API_KEY" 213 | onVerify={recaptchaResponse => { 214 | // Do something 215 | }} 216 | /> 217 | 218 | 219 | ``` 220 | 221 | ### Instance methods 222 | 223 | It's known that calling methods of a React component class is a really bad practice, but as we're doing something 224 | uncommon to typical React components - it's the only method that popped in that actually is intuitive and React-ish. 225 | 226 | So to get access to the methods, just save the reference to the component instance: 227 | 228 | ```jsx 229 | (this.captcha = e)} /> 230 | ``` 231 | 232 | **If you have an idea how to do this better, feel free to file an issue to discuss it!** 233 | 234 | Available and usable `Reaptcha` instance methods: 235 | 236 | | Name | Returns | Description | 237 | | ---------------- | ----------------- | ----------------------------------------- | 238 | | renderExplicitly | `Promise` | Renders the reCAPTCHA instance explicitly | 239 | | reset | `Promise` | Resets the reCAPTCHA instance | 240 | | execute | `Promise` | Executes the reCAPTCHA instance | 241 | | getResponse | `Promise` | Returns the reCATPCHA response | 242 | 243 | ### Render prop 244 | 245 | Using instance methods can be avoided by passing `children` render function. 246 | 247 | ```jsx 248 | 249 | {({ renderExplicitly, reset, execute, recaptchaComponent }) => { 250 | return ( 251 |
252 | {recaptchaComponent} 253 | 254 | 255 |
256 | ); 257 | }} 258 |
259 | ``` 260 | 261 | When passing `children` render prop, you are responsible for rendering `recaptchaComponent` into the DOM. 262 | 263 | ## Customisation 264 | 265 | `Reaptcha` allows to customize your reCAPTCHA instances with any available properties documented in the reCAPTCHA docs 266 | for all of the types: 267 | 268 | - [I'm a robot](https://developers.google.com/recaptcha/docs/display#render_param) 269 | - [Invisible](https://developers.google.com/recaptcha/docs/invisible#render_param) 270 | 271 | | Name | Required | Type | Default | Description | 272 | | --------- | ------------------ | ------------------------------------------- | --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | 273 | | id | :heavy_minus_sign: | `string` | - | Id for the container element | 274 | | className | :heavy_minus_sign: | `string` | `'g-recaptcha'` | Classname for the container element | 275 | | sitekey | :heavy_plus_sign: | `string` | - | Your reCAPTCHA API key | 276 | | theme | :heavy_minus_sign: | `'light' \| 'dark'` | `'light'` | reCAPTCHA color theme | 277 | | size | :heavy_minus_sign: | `'compact' \| 'normal' \| 'invisible'` | `'normal'` | reCAPTCHA size | 278 | | badge | :heavy_minus_sign: | `'bottomright' \| 'bottomleft' \| 'inline'` | `'bottomright'` | Position of the reCAPTCHA badge | 279 | | tabindex | :heavy_minus_sign: | `number` | 0 | Tabindex of the challenge | 280 | | explicit | :heavy_minus_sign: | `boolean` | false | Allows to explicitly render reCAPTCHA | 281 | | inject | :heavy_minus_sign: | `boolean` | true | Handle reCAPTCHA script DOM injection automatically | 282 | | isolated | :heavy_minus_sign: | `boolean` | false | For plugin owners to not interfere with existing reCAPTCHA installations on a page | 283 | | hl | :heavy_minus_sign: | `string` | - | [Language code](https://developers.google.com/recaptcha/docs/language) for reCAPTCHA | | 284 | | onLoad | :heavy_minus_sign: | `Function` | - | Callback function executed when the reCAPTCHA script sucessfully loads | 285 | | onRender | :heavy_minus_sign: | `Function` | - | Callback function executed when the reCAPTCHA successfuly renders | 286 | | onVerify | :heavy_plus_sign: | `Function` | - | Callback function executed on user's captcha verification. It's being called with the [user response token](https://developers.google.com/recaptcha/docs/verify) | 287 | | onExpire | :heavy_minus_sign: | `Function` | - | Callback function executed when the reCAPTCHA response expires and the user needs to re-verify | 288 | | onError | :heavy_minus_sign: | `Function` | - | Callback function executed when reCAPTCHA fails with an error | 289 | | children | :heavy_minus_sign: | `Function` | - | Render function that can be used to get access to instance methods without the need to explicitly use refs | 290 | 291 | ## Caveats 292 | 293 | ### Array.from 294 | 295 | This library is using `Array.from` which 296 | is [not supported by few browsers](https://developer.mozilla.org/pl/docs/Web/JavaScript/Referencje/Obiekty/Array/from) 297 | i.e. Internet Explorer or Opera. If you want to use `reaptcha` and keep supporting these browsers, you need to use a 298 | polyfill for it. 299 | 300 | ### Size-specific props 301 | 302 | There are props that are size-specific and some of the props are **not available** for all of the sizes. Although if you 303 | will pass these props nothing bad will happen, they will just be ignored. 304 | 305 | The size-exclusive props are: 306 | 307 | - I'm a robot: `theme` 308 | - Invisible: `badge`, `isolated` 309 | 310 | ### Attaching reCAPTCHA script manually 311 | 312 | If you want to attach the reCAPTCHA script manually to the DOM, simply pass the `inject` prop as `false`, like this: 313 | 314 | `` 315 | 316 | This way `Reaptcha` won't inject the scripts by itself and won't break because of multiple reCAPTCHA scripts attached. 317 | 318 | ## Donate 319 | 320 | If you want to support my work, feel free to... buy me a pizza :) 321 | 322 | [![Donate](https://img.buymeacoffee.com/button-api/?text=Buy%20me%20a%20pizza&emoji=🍕&slug=jsarnowski&button_colour=FFDD00&font_colour=000000&font_family=Bree&outline_colour=000000&coffee_colour=ffffff)](https://www.buymeacoffee.com/jsarnowski) 323 | -------------------------------------------------------------------------------- /lib/index.test.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-empty-function */ 2 | 3 | import React from 'react'; 4 | import test from 'ava'; 5 | import sinon from 'sinon'; 6 | import jsdom from 'jsdom-global'; 7 | import Enzyme, { mount } from 'enzyme'; 8 | import Adapter from '@wojtekmaj/enzyme-adapter-react-17'; 9 | 10 | import Reaptcha, { Grecaptcha, Props } from './index'; 11 | 12 | jsdom(); 13 | 14 | Enzyme.configure({ adapter: new Adapter() }); 15 | 16 | const renderSpy = sinon.spy(); 17 | const executeSpy = sinon.spy(); 18 | const resetSpy = sinon.spy(); 19 | const getResponseStub = sinon.stub().returns('stubbed-response'); 20 | 21 | const defaultProps = { 22 | sitekey: 'some-key', 23 | onVerify: () => {} 24 | }; 25 | 26 | const stubGrecaptcha = (mockedProps: Partial) => { 27 | window.grecaptcha = { 28 | ...(window.grecaptcha as Grecaptcha), 29 | ...mockedProps 30 | }; 31 | }; 32 | 33 | test.beforeEach(() => { 34 | Array.from(document.scripts).forEach(script => script.remove()); 35 | 36 | window.grecaptcha = { 37 | ready: callback => callback(), 38 | render: renderSpy, 39 | reset: resetSpy, 40 | execute: executeSpy, 41 | getResponse: getResponseStub 42 | }; 43 | 44 | renderSpy.resetHistory(); 45 | executeSpy.resetHistory(); 46 | resetSpy.resetHistory(); 47 | getResponseStub.resetHistory(); 48 | }); 49 | 50 | test('should pass id', t => { 51 | t.plan(1); 52 | 53 | const wrapper = mount(); 54 | 55 | t.true( 56 | wrapper 57 | .find('#some-id') 58 | .hostNodes() 59 | .exists() 60 | ); 61 | }); 62 | 63 | test('should pass className', t => { 64 | t.plan(1); 65 | 66 | const wrapper = mount( 67 | 68 | ); 69 | 70 | t.true( 71 | wrapper 72 | .find('.class-1') 73 | .hostNodes() 74 | .exists() 75 | ); 76 | }); 77 | 78 | test('should have default className', t => { 79 | t.plan(1); 80 | 81 | const wrapper = mount(); 82 | 83 | t.true( 84 | wrapper 85 | .find('.g-recaptcha') 86 | .hostNodes() 87 | .exists() 88 | ); 89 | }); 90 | 91 | test('should execute render prop if passed', t => { 92 | t.plan(2); 93 | 94 | const childrenSpy = sinon.spy(({ recaptchaComponent }) => recaptchaComponent); 95 | 96 | mount({childrenSpy}); 97 | 98 | t.true(childrenSpy.called); 99 | t.true( 100 | childrenSpy.alwaysCalledWith( 101 | sinon.match({ 102 | renderExplicitly: sinon.match.func, 103 | reset: sinon.match.func, 104 | execute: sinon.match.func, 105 | recaptchaComponent: sinon.match.defined 106 | }) 107 | ) 108 | ); 109 | }); 110 | 111 | test('should render recaptcha', t => { 112 | t.plan(2); 113 | 114 | const wrapper = mount( 115 | 122 | ); 123 | 124 | const instance = wrapper.instance(); 125 | 126 | t.true(renderSpy.calledOnce); 127 | t.true( 128 | renderSpy.calledWith(sinon.match.any, { 129 | sitekey: 'my-key', 130 | theme: 'dark', 131 | size: 'normal', 132 | badge: undefined, 133 | tabindex: 2, 134 | callback: instance._onVerify, 135 | 'expired-callback': instance._onExpire, 136 | 'error-callback': instance._onError, 137 | isolated: undefined, 138 | hl: '' 139 | }) 140 | ); 141 | }); 142 | 143 | test('should render recaptcha on delayed grecaptcha load', t => { 144 | t.plan(1); 145 | const clock = sinon.useFakeTimers(); 146 | 147 | window.grecaptcha = undefined; 148 | setTimeout(() => { 149 | window.grecaptcha = { 150 | ready: callback => callback(), 151 | render: renderSpy, 152 | reset: resetSpy, 153 | execute: executeSpy, 154 | getResponse: () => '' 155 | }; 156 | }, 500); 157 | 158 | mount(); 159 | 160 | clock.tick(500); 161 | 162 | t.true(renderSpy.calledOnce); 163 | 164 | clock.restore(); 165 | }); 166 | 167 | test('should render invisible recaptcha', t => { 168 | t.plan(2); 169 | 170 | const wrapper = mount( 171 | 180 | ); 181 | 182 | const instance = wrapper.instance(); 183 | 184 | t.true(renderSpy.calledOnce); 185 | t.true( 186 | renderSpy.calledWith(sinon.match.any, { 187 | sitekey: 'my-key', 188 | theme: 'light', 189 | size: 'invisible', 190 | badge: 'bottomleft', 191 | tabindex: 3, 192 | callback: instance._onVerify, 193 | 'expired-callback': instance._onExpire, 194 | 'error-callback': instance._onError, 195 | isolated: true, 196 | hl: undefined 197 | }) 198 | ); 199 | }); 200 | 201 | test('should render invisible recaptcha in dark mode', t => { 202 | t.plan(2); 203 | 204 | const wrapper = mount( 205 | 215 | ); 216 | 217 | const instance = wrapper.instance(); 218 | 219 | t.true(renderSpy.calledOnce); 220 | t.true( 221 | renderSpy.calledWith(sinon.match.any, { 222 | sitekey: 'my-key', 223 | theme: 'dark', 224 | size: 'invisible', 225 | badge: 'bottomleft', 226 | tabindex: 3, 227 | callback: instance._onVerify, 228 | 'expired-callback': instance._onExpire, 229 | 'error-callback': instance._onError, 230 | isolated: true, 231 | hl: undefined 232 | }) 233 | ); 234 | }); 235 | 236 | test('should render recaptcha with hl', t => { 237 | t.plan(1); 238 | 239 | mount(); 240 | 241 | t.true( 242 | renderSpy.calledWith( 243 | sinon.match.any, 244 | sinon.match({ 245 | hl: 'fr' 246 | }) 247 | ) 248 | ); 249 | }); 250 | 251 | test('should render recaptcha explicitly', t => { 252 | t.plan(2); 253 | 254 | const wrapper = mount(); 255 | 256 | t.false(renderSpy.calledOnce); 257 | 258 | return wrapper 259 | .instance() 260 | .renderExplicitly() 261 | .then(() => { 262 | t.true(renderSpy.calledOnce); 263 | }); 264 | }); 265 | 266 | test('should not render recaptcha when grecaptcha not available', t => { 267 | t.plan(1); 268 | const clock = sinon.useFakeTimers(); 269 | window.grecaptcha = undefined; 270 | 271 | mount(); 272 | 273 | clock.tick(1000); 274 | 275 | t.false(renderSpy.calledOnce); 276 | 277 | clock.restore(); 278 | }); 279 | 280 | test('should reset recaptcha', t => { 281 | t.plan(2); 282 | 283 | stubGrecaptcha({ render: sinon.stub().returns('reset-test-id') }); 284 | 285 | const wrapper = mount(); 286 | 287 | return wrapper 288 | .instance() 289 | .reset() 290 | .then(() => { 291 | t.true(resetSpy.calledOnce); 292 | t.true(resetSpy.calledWith('reset-test-id')); 293 | }); 294 | }); 295 | 296 | test('should execute recaptcha', t => { 297 | t.plan(2); 298 | 299 | stubGrecaptcha({ render: sinon.stub().returns('execute-test-id') }); 300 | const wrapper = mount( 301 | 302 | ); 303 | 304 | return wrapper 305 | .instance() 306 | .execute() 307 | .then(() => { 308 | t.true(executeSpy.calledOnce); 309 | t.true(executeSpy.calledWith('execute-test-id')); 310 | }); 311 | }); 312 | 313 | test('should getResponse from recaptcha', t => { 314 | t.plan(3); 315 | 316 | stubGrecaptcha({ render: sinon.stub().returns('get-response-test-id') }); 317 | const wrapper = mount( 318 | 319 | ); 320 | 321 | return wrapper 322 | .instance() 323 | .getResponse() 324 | .then(response => { 325 | t.true('stubbed-response' === response); 326 | t.true(getResponseStub.calledOnce); 327 | t.true(getResponseStub.calledWith('get-response-test-id')); 328 | }); 329 | }); 330 | 331 | test('should inject script', t => { 332 | t.plan(4); 333 | 334 | mount(); 335 | t.is(document.scripts.length, 1); 336 | const script = document.scripts[0]; 337 | t.true(script.async); 338 | t.true(script.defer); 339 | t.truthy(script.src); 340 | }); 341 | 342 | test('should not inject script', t => { 343 | t.plan(1); 344 | 345 | mount(); 346 | 347 | t.is(document.scripts.length, 0); 348 | }); 349 | 350 | [ 351 | 'https://google.com/recaptcha', 352 | 'https://www.google.com/recaptcha', 353 | 'https://recaptcha.net/recaptcha', 354 | 'https://www.recaptcha.net/recaptcha', 355 | 'https://gstatic.com/recaptcha', 356 | 'https://www.gstatic.com/recaptcha' 357 | ].forEach(src => { 358 | test(`should not inject script if one with src ${src} already present`, t => { 359 | t.plan(1); 360 | 361 | const script = document.createElement('script'); 362 | script.src = src; 363 | document.head.appendChild(script); 364 | 365 | mount(); 366 | 367 | t.is(document.scripts.length, 1); 368 | }); 369 | }); 370 | 371 | test('should inject script with hl parameter if specified', t => { 372 | t.plan(3); 373 | 374 | mount(); 375 | 376 | t.is(document.scripts.length, 1); 377 | const script = document.scripts[0]; 378 | t.truthy(script.src); 379 | t.regex(script.src, new RegExp('hl=en')); 380 | }); 381 | 382 | test('should inject script without hl parameter if not specified', t => { 383 | t.plan(3); 384 | 385 | mount(); 386 | 387 | t.is(document.scripts.length, 1); 388 | const script = document.scripts[0]; 389 | t.truthy(script.src); 390 | t.notRegex(script.src, new RegExp('hl=')); 391 | }); 392 | 393 | test('should inject only one script on multiple instances', t => { 394 | t.plan(1); 395 | 396 | mount( 397 |
398 | 399 | 400 | 401 |
402 | ); 403 | 404 | t.is(document.scripts.length, 1); 405 | }); 406 | 407 | test('should call onLoad', t => { 408 | t.plan(1); 409 | 410 | const onLoadSpy = sinon.spy(); 411 | mount(); 412 | 413 | t.true(onLoadSpy.calledOnce); 414 | }); 415 | 416 | test('should call onRender', t => { 417 | t.plan(1); 418 | 419 | const onRender = sinon.spy(); 420 | mount(); 421 | 422 | t.true(onRender.calledOnce); 423 | }); 424 | 425 | test('should call onVerify', t => { 426 | t.plan(2); 427 | 428 | const onVerify = sinon.spy(); 429 | const wrapper = mount( 430 | 431 | ); 432 | 433 | wrapper.instance()._onVerify('response'); 434 | 435 | t.true(onVerify.calledOnce); 436 | t.true(onVerify.calledWith('response')); 437 | }); 438 | 439 | test('should call onExpire', t => { 440 | t.plan(1); 441 | 442 | const onExpire = sinon.spy(); 443 | const wrapper = mount( 444 | 445 | ); 446 | 447 | wrapper.instance()._onExpire(); 448 | 449 | t.true(onExpire.calledOnce); 450 | }); 451 | 452 | test('should call onError', t => { 453 | t.plan(1); 454 | 455 | const onError = sinon.spy(); 456 | const wrapper = mount( 457 | 458 | ); 459 | 460 | wrapper.instance()._onError(); 461 | 462 | t.true(onError.calledOnce); 463 | }); 464 | 465 | test('should throw error on double render', t => { 466 | t.plan(2); 467 | 468 | const wrapper = mount(); 469 | const instance = wrapper.instance(); 470 | 471 | return instance.renderExplicitly().then(() => { 472 | t.true(renderSpy.calledOnce); 473 | return t.throwsAsync( 474 | instance.renderExplicitly(), 475 | undefined, 476 | 'This recaptcha instance has been already rendered.' 477 | ); 478 | }); 479 | }); 480 | 481 | test('should throw error when no grecaptcha available on render', t => { 482 | t.plan(1); 483 | 484 | stubGrecaptcha({ 485 | ready: () => {} 486 | }); 487 | const wrapper = mount(); 488 | 489 | return t.throwsAsync( 490 | wrapper.instance().renderExplicitly(), 491 | undefined, 492 | 'Recaptcha is not ready for rendering yet.' 493 | ); 494 | }); 495 | 496 | test('should throw error when recaptcha not rendered on reset', t => { 497 | t.plan(1); 498 | 499 | const wrapper = mount(); 500 | 501 | return t.throwsAsync( 502 | wrapper.instance().reset(), 503 | undefined, 504 | 'This recaptcha instance did not render yet.' 505 | ); 506 | }); 507 | 508 | test('should throw error when trying to execute while not in invisible mode', t => { 509 | t.plan(1); 510 | 511 | const wrapper = mount(); 512 | 513 | return t.throwsAsync( 514 | wrapper.instance().execute(), 515 | undefined, 516 | 'Manual execution is only available for invisible size.' 517 | ); 518 | }); 519 | 520 | test('should throw error when trying to execute while not rendered', t => { 521 | t.plan(1); 522 | 523 | const wrapper = mount( 524 | 525 | ); 526 | 527 | return t.throwsAsync( 528 | wrapper.instance().execute(), 529 | undefined, 530 | 'This recaptcha instance did not render yet.' 531 | ); 532 | }); 533 | 534 | test('should throw error when trying to getResponse while not rendered', t => { 535 | t.plan(1); 536 | 537 | const wrapper = mount( 538 | 539 | ); 540 | 541 | return t.throwsAsync( 542 | wrapper.instance().getResponse(), 543 | undefined, 544 | 'This recaptcha instance did not render yet.' 545 | ); 546 | }); 547 | 548 | test('should not throw error on unmount when not rendered', t => { 549 | t.plan(1); 550 | 551 | const wrapper = mount(); 552 | 553 | t.notThrows(() => wrapper.unmount()); 554 | }); 555 | 556 | test('should derive invisible state from size prop', t => { 557 | t.plan(2); 558 | 559 | const wrapper = mount(); 560 | 561 | t.is(wrapper.state('invisible'), false); 562 | 563 | wrapper.setProps({ size: 'invisible' }); 564 | 565 | t.is(wrapper.state('invisible'), true); 566 | }); 567 | 568 | const propsThatShouldRerenderRecaptcha: Array> = [ 569 | { sitekey: 'new-sitekey' }, 570 | { theme: 'dark' }, 571 | { size: 'invisible' }, 572 | { badge: 'bottomleft' }, 573 | { tabindex: 100 }, 574 | { hl: 'ja' }, 575 | { isolated: true } 576 | ]; 577 | 578 | propsThatShouldRerenderRecaptcha.forEach(props => { 579 | test(`should rerender recaptcha when changing ${ 580 | Object.keys(props)[0] 581 | } prop`, t => { 582 | t.plan(1); 583 | 584 | const wrapper = mount(); 585 | 586 | const recaptchaElement = wrapper.find('div'); 587 | const recaptchaElementKey = recaptchaElement.key(); 588 | 589 | return new Promise(resolve => { 590 | wrapper.setState({ rendered: true }, () => { 591 | wrapper.setProps(props as Pick, () => { 592 | /** 593 | * We're doing a setState() in componentDidUpdate 594 | * and Enzyme probably doesn't wait for this to process. 595 | * This is the cleanest workaround I found. 596 | */ 597 | setTimeout(() => { 598 | wrapper.update(); 599 | const newRecaptchaElement = wrapper.find('div'); 600 | const newRecaptchaElementKey = newRecaptchaElement.key(); 601 | 602 | t.not(recaptchaElementKey, newRecaptchaElementKey); 603 | 604 | resolve(); 605 | }, 0); 606 | }); 607 | }); 608 | }); 609 | }); 610 | }); 611 | -------------------------------------------------------------------------------- /lib/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component, ReactNode } from 'react'; 2 | 3 | import injectScript from './utils/injectScript'; 4 | import isAnyScriptPresent from './utils/isAnyScriptPresent'; 5 | 6 | declare global { 7 | interface Window { 8 | grecaptcha?: Grecaptcha; 9 | } 10 | } 11 | 12 | export type Grecaptcha = { 13 | ready: (callback: () => void) => void; 14 | render: (container?: HTMLElement, config?: RecaptchaConfig) => number; 15 | reset: (id?: number) => void; 16 | execute: (id?: number) => void; 17 | getResponse: (id?: number) => string; 18 | }; 19 | 20 | type RecaptchaBaseConfig = { 21 | sitekey: string; 22 | theme?: 'light' | 'dark'; 23 | size?: 'compact' | 'normal' | 'invisible'; 24 | badge?: 'bottomright' | 'bottomleft' | 'inline'; 25 | tabindex?: number; 26 | hl?: string; 27 | isolated?: boolean; 28 | }; 29 | 30 | type RecaptchaConfig = RecaptchaBaseConfig & { 31 | callback?: (response: string) => void; 32 | 'expired-callback'?: () => void; 33 | 'error-callback'?: () => void; 34 | }; 35 | 36 | export type RenderProps = { 37 | renderExplicitly: () => Promise; 38 | reset: () => Promise; 39 | execute: () => Promise; 40 | getResponse: () => Promise; 41 | recaptchaComponent: ReactNode; 42 | }; 43 | 44 | export type Props = RecaptchaBaseConfig & { 45 | id?: string; 46 | className?: string; 47 | explicit?: boolean; 48 | onLoad?: () => void; 49 | onRender?: () => void; 50 | onVerify: (response: string) => void; 51 | onExpire?: () => void; 52 | onError?: () => void; 53 | inject?: boolean; 54 | children?: (renderProps: RenderProps) => ReactNode; 55 | }; 56 | 57 | type State = { 58 | instanceKey: number; 59 | instanceId?: number; 60 | ready: boolean; 61 | rendered: boolean; 62 | invisible: boolean; 63 | }; 64 | 65 | const RECAPTCHA_SCRIPT_URL = 'https://recaptcha.net/recaptcha/api.js'; 66 | const RECAPTCHA_SCRIPT_REGEX = /(http|https):\/\/(www)?.+\/recaptcha/; 67 | 68 | const PROPS_THAT_SHOULD_CAUSE_RERENDER: Array = [ 69 | 'sitekey', 70 | 'theme', 71 | 'size', 72 | 'badge', 73 | 'tabindex', 74 | 'hl', 75 | 'isolated' 76 | ]; 77 | 78 | class Reaptcha extends Component { 79 | container?: HTMLDivElement | null; 80 | timer?: number | undefined; 81 | 82 | state: State = { 83 | instanceKey: Date.now(), 84 | ready: false, 85 | rendered: false, 86 | invisible: this.props.size === 'invisible' 87 | }; 88 | 89 | static defaultProps: Partial = { 90 | id: '', 91 | className: 'g-recaptcha', 92 | theme: 'light', 93 | size: 'normal', 94 | badge: 'bottomright', 95 | tabindex: 0, 96 | explicit: false, 97 | inject: true, 98 | isolated: false, 99 | hl: '' 100 | }; 101 | 102 | static getDerivedStateFromProps(props: Props, state: State) { 103 | const invisible = props.size === 'invisible'; 104 | if (invisible !== state.invisible) { 105 | return { invisible }; 106 | } 107 | return null; 108 | } 109 | 110 | _isAvailable = (): boolean => Boolean(window.grecaptcha?.ready); 111 | 112 | _inject = (): void => { 113 | if (this.props.inject && !isAnyScriptPresent(RECAPTCHA_SCRIPT_REGEX)) { 114 | const hlParam = this.props.hl ? `&hl=${this.props.hl}` : ''; 115 | const src = `${RECAPTCHA_SCRIPT_URL}?render=explicit${hlParam}`; 116 | injectScript(src); 117 | } 118 | }; 119 | 120 | _prepare = (): void => { 121 | const { explicit, onLoad } = this.props; 122 | // @ts-expect-error: Unreachable code error. We ensure window.grecaptcha is available before executing this method. 123 | window.grecaptcha.ready(() => { 124 | this.setState({ ready: true }, () => { 125 | if (!explicit) { 126 | this.renderExplicitly(); 127 | } 128 | if (onLoad) { 129 | onLoad(); 130 | } 131 | }); 132 | }); 133 | }; 134 | 135 | _renderRecaptcha = ( 136 | container: HTMLDivElement, 137 | config: RecaptchaConfig 138 | // @ts-expect-error: Unreachable code error. We ensure window.grecaptcha is available before executing this method. 139 | ): number => window.grecaptcha.render(container, config); 140 | 141 | // @ts-expect-error: Unreachable code error. We ensure window.grecaptcha is available before executing this method. 142 | _resetRecaptcha = (): void => window.grecaptcha.reset(this.state.instanceId); 143 | 144 | _executeRecaptcha = (): void => 145 | // @ts-expect-error: Unreachable code error. We ensure window.grecaptcha is available before executing this method. 146 | window.grecaptcha.execute(this.state.instanceId); 147 | 148 | _getResponseRecaptcha = (): string => 149 | // @ts-expect-error: Unreachable code error. We ensure window.grecaptcha is available before executing this method. 150 | window.grecaptcha.getResponse(this.state.instanceId); 151 | 152 | _onVerify = (response: string) => this.props.onVerify(response); 153 | 154 | _onExpire = () => this.props.onExpire && this.props.onExpire(); 155 | 156 | _onError = () => this.props.onError && this.props.onError(); 157 | 158 | _stopTimer = (): void => { 159 | if (this.timer) { 160 | clearInterval(this.timer); 161 | } 162 | }; 163 | 164 | componentDidMount = (): void => { 165 | this._inject(); 166 | 167 | if (this._isAvailable()) { 168 | this._prepare(); 169 | } else { 170 | this.timer = window.setInterval(() => { 171 | if (this._isAvailable()) { 172 | this._prepare(); 173 | this._stopTimer(); 174 | } 175 | }, 500); 176 | } 177 | }; 178 | 179 | componentDidUpdate(prevProps: Readonly) { 180 | const changedProps = PROPS_THAT_SHOULD_CAUSE_RERENDER.reduce< 181 | Array 182 | >((changedProps, key) => { 183 | if (this.props[key] !== prevProps[key]) { 184 | return [...changedProps, key]; 185 | } 186 | return changedProps; 187 | }, []); 188 | 189 | if (changedProps.length > 0) { 190 | this.setState( 191 | { 192 | instanceKey: Date.now(), 193 | rendered: false 194 | }, 195 | () => { 196 | if (!this.props.explicit) { 197 | this.renderExplicitly(); 198 | } 199 | } 200 | ); 201 | } 202 | } 203 | 204 | componentWillUnmount = (): void => { 205 | this._stopTimer(); 206 | }; 207 | 208 | renderExplicitly = (): Promise => { 209 | return new Promise((resolve, reject) => { 210 | if (this.state.rendered) { 211 | return reject( 212 | new Error('This recaptcha instance has been already rendered.') 213 | ); 214 | } 215 | if (this.state.ready && this.container) { 216 | const instanceId = this._renderRecaptcha(this.container, { 217 | sitekey: this.props.sitekey, 218 | theme: this.props.theme, 219 | size: this.props.size, 220 | badge: this.state.invisible ? this.props.badge : undefined, 221 | tabindex: this.props.tabindex, 222 | callback: this._onVerify, 223 | 'expired-callback': this._onExpire, 224 | 'error-callback': this._onError, 225 | isolated: this.state.invisible ? this.props.isolated : undefined, 226 | hl: this.state.invisible ? undefined : this.props.hl 227 | }); 228 | 229 | this.setState( 230 | { 231 | instanceId, 232 | rendered: true 233 | }, 234 | () => { 235 | if (this.props.onRender) { 236 | this.props.onRender(); 237 | } 238 | resolve(); 239 | } 240 | ); 241 | } else { 242 | return reject(new Error('Recaptcha is not ready for rendering yet.')); 243 | } 244 | }); 245 | }; 246 | 247 | reset = (): Promise => { 248 | return new Promise((resolve, reject) => { 249 | if (this.state.rendered) { 250 | this._resetRecaptcha(); 251 | return resolve(); 252 | } 253 | reject(new Error('This recaptcha instance did not render yet.')); 254 | }); 255 | }; 256 | 257 | execute = (): Promise => { 258 | return new Promise((resolve, reject) => { 259 | if (!this.state.invisible) { 260 | return reject( 261 | new Error('Manual execution is only available for invisible size.') 262 | ); 263 | } 264 | if (this.state.rendered) { 265 | this._executeRecaptcha(); 266 | resolve(); 267 | } 268 | return reject(new Error('This recaptcha instance did not render yet.')); 269 | }); 270 | }; 271 | 272 | getResponse = (): Promise => { 273 | return new Promise((resolve, reject) => { 274 | if (this.state.rendered) { 275 | const response = this._getResponseRecaptcha(); 276 | return resolve(response); 277 | } 278 | reject(new Error('This recaptcha instance did not render yet.')); 279 | }); 280 | }; 281 | 282 | render = () => { 283 | const container = ( 284 |
(this.container = e)} 289 | /> 290 | ); 291 | 292 | return this.props.children 293 | ? this.props.children({ 294 | renderExplicitly: this.renderExplicitly, 295 | reset: this.reset, 296 | execute: this.execute, 297 | getResponse: this.getResponse, 298 | recaptchaComponent: container 299 | }) 300 | : container; 301 | }; 302 | } 303 | 304 | export default Reaptcha; 305 | -------------------------------------------------------------------------------- /lib/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "reaptcha", 3 | "version": "1.12.1", 4 | "description": "Google reCAPTCHA for React", 5 | "repository": { 6 | "type": "git", 7 | "url": "https://github.com/sarneeh/reaptcha" 8 | }, 9 | "license": "MIT", 10 | "author": "Jakub Sarnowski ", 11 | "files": [ 12 | "dist", 13 | "index.d.ts" 14 | ], 15 | "source": "./index.tsx", 16 | "main": "dist/index.js", 17 | "module": "dist/index.esm.js", 18 | "unpkg": "dist/index.umd.js", 19 | "typings": "./index.d.ts", 20 | "scripts": { 21 | "dev": "npm run build -- -w", 22 | "build": "rimraf dist && microbundle --jsx React.createElement" 23 | }, 24 | "peerDependencies": { 25 | "react": "^16 || ^17 || ^18" 26 | }, 27 | "devDependencies": { 28 | "microbundle": "^0.14.2", 29 | "rimraf": "^3.0.2" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /lib/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.common.json", 3 | "compilerOptions": { 4 | "noEmit": true 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /lib/utils/injectScript.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-global-assign */ 2 | 3 | import test from 'ava'; 4 | import sinon from 'sinon'; 5 | import jsdom from 'jsdom-global'; 6 | 7 | import injectScript from './injectScript'; 8 | 9 | jsdom(); 10 | 11 | test.serial('should inject script', t => { 12 | const scriptObj = {} as HTMLScriptElement; 13 | 14 | const appendChildSpy = sinon.spy(); 15 | const createElementStub = sinon 16 | .stub() 17 | .withArgs('script') 18 | .returns(scriptObj); 19 | 20 | sinon.stub(document, 'createElement').get(() => createElementStub); 21 | sinon.stub(document.head, 'appendChild').get(() => appendChildSpy); 22 | 23 | injectScript('src'); 24 | 25 | t.true(scriptObj.async); 26 | t.true(scriptObj.async); 27 | t.is(scriptObj.src, 'src'); 28 | t.true(appendChildSpy.calledOnceWith(scriptObj)); 29 | }); 30 | 31 | test.serial('should not throw when document head not present', t => { 32 | const createElementStub = sinon 33 | .stub() 34 | .withArgs('script') 35 | .returns({}); 36 | 37 | sinon.stub(document, 'createElement').get(() => createElementStub); 38 | sinon.stub(document, 'head').get(() => null); 39 | 40 | t.notThrows(() => injectScript('src')); 41 | }); 42 | -------------------------------------------------------------------------------- /lib/utils/injectScript.ts: -------------------------------------------------------------------------------- 1 | export default (scriptSrc: string): void => { 2 | const script = document.createElement('script'); 3 | 4 | script.async = true; 5 | script.defer = true; 6 | script.src = scriptSrc; 7 | 8 | if (document.head) { 9 | document.head.appendChild(script); 10 | } 11 | }; 12 | -------------------------------------------------------------------------------- /lib/utils/isAnyScriptPresent.test.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import sinon from 'sinon'; 3 | import jsdom from 'jsdom-global'; 4 | 5 | import isAnyScriptPresent from './isAnyScriptPresent'; 6 | 7 | jsdom(); 8 | 9 | sinon 10 | .stub(document, 'scripts') 11 | .get(() => [ 12 | { src: 'https://first.url?render=explicit' }, 13 | { src: 'https://second.url?hl=en' }, 14 | { src: 'https://third.url?hl=en' } 15 | ]); 16 | 17 | test('should return true', t => { 18 | t.true(isAnyScriptPresent(/https:\/\/second.url.*/)); 19 | }); 20 | 21 | test('should return false', t => { 22 | t.false(isAnyScriptPresent(/https:\/\/unknown.url.*/)); 23 | }); 24 | -------------------------------------------------------------------------------- /lib/utils/isAnyScriptPresent.ts: -------------------------------------------------------------------------------- 1 | export default (regex: RegExp): boolean => 2 | Array.from(document.scripts).reduce( 3 | (isPresent, script) => (isPresent ? isPresent : regex.test(script.src)), 4 | false 5 | ); 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "reaptcha-monorepo", 3 | "private": true, 4 | "workspaces": [ 5 | "lib", 6 | "example" 7 | ], 8 | "scripts": { 9 | "lint": "eslint .", 10 | "test": "ava --serial 'lib/**/*.test.{ts,tsx}'", 11 | "test:coverage": "c8 --reporter lcovonly --clean --100 npm test", 12 | "dev": "turbo run dev --parallel --no-cache", 13 | "build": "NODE_ENV=production turbo run build", 14 | "version": "changeset version", 15 | "publish": "npm run build && changeset publish" 16 | }, 17 | "devDependencies": { 18 | "@changesets/cli": "^2.21.0", 19 | "@commitlint/cli": "^8.3.5", 20 | "@commitlint/config-conventional": "^8.3.4", 21 | "@types/enzyme": "^3.10.11", 22 | "@types/jsdom-global": "^3.0.2", 23 | "@types/node": "^17.0.21", 24 | "@types/react": "^18.0.12", 25 | "@types/react-dom": "^18.0.5", 26 | "@types/sinon": "^10.0.11", 27 | "@typescript-eslint/eslint-plugin": "^5.13.0", 28 | "@typescript-eslint/parser": "^5.13.0", 29 | "@wojtekmaj/enzyme-adapter-react-17": "^0.6.7", 30 | "ava": "^4.0.1", 31 | "c8": "^7.10.0", 32 | "cz-conventional-changelog": "^3.1.0", 33 | "enzyme": "^3.11.0", 34 | "eslint": "^8.10.0", 35 | "eslint-plugin-react": "^7.29.2", 36 | "husky": "^4.2.3", 37 | "jsdom": "^16.2.0", 38 | "jsdom-global": "^3.0.2", 39 | "prettier": "^1.19.1", 40 | "pretty-quick": "^2.0.1", 41 | "sinon": "^9.0.0", 42 | "ts-node": "^10.7.0", 43 | "turbo": "^1.1.4", 44 | "typescript": "~4.5.0" 45 | }, 46 | "husky": { 47 | "hooks": { 48 | "commit-msg": "commitlint -E HUSKY_GIT_PARAMS", 49 | "pre-commit": "pretty-quick --staged" 50 | } 51 | }, 52 | "ava": { 53 | "require": [ 54 | "ts-node/register" 55 | ], 56 | "extensions": [ 57 | "ts", 58 | "tsx" 59 | ], 60 | "environmentVariables": { 61 | "TS_NODE_PROJECT": "./tsconfig.test.json" 62 | } 63 | }, 64 | "config": { 65 | "commitizen": { 66 | "path": "cz-conventional-changelog" 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /tsconfig.common.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "esModuleInterop": true, 4 | "target": "esnext", 5 | "module": "esnext", 6 | "moduleResolution": "node", 7 | "jsx": "react", 8 | "strict": true 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.common.json", 3 | "compilerOptions": { 4 | "target": "es2015", 5 | "module": "commonjs", 6 | "noImplicitAny": false 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /turbo.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://turborepo.org/schema.json", 3 | "pipeline": { 4 | "lint": {}, 5 | "test": {}, 6 | "test:coverage": {}, 7 | "dev": { 8 | "cache": false 9 | }, 10 | "build": { 11 | "dependsOn": ["^build"] 12 | }, 13 | "publish": { 14 | "dependsOn": ["build"] 15 | } 16 | } 17 | } 18 | --------------------------------------------------------------------------------