Google Recaptcha integration for button and form button widgets. (#1118)

* Adding base code for google re-captcha.

* Removing recaptcha as well

* Adding recaptchaToken on Button.

* Fixing updateMetaProperty errors.

* Handling recaptcha generation failed case.

* Adding a message for recaptcha token gen fail

* Rename setRecaptchaToken

* Adding loading state for recaptcha button

* Adding googleRecaptchaKey as a Btn prop

* Adding the bound functions in widgets

* Removing unused vars.

* Handling google recaptcha key error.

* Adding proper messages for
This commit is contained in:
satbir121 2020-10-12 18:31:19 +05:30 committed by GitHub
parent f9f85a8897
commit 8bf80fe507
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 238 additions and 18 deletions

View File

@ -207,4 +207,4 @@
"pre-commit": "lint-staged"
}
}
}
}

View File

@ -10,6 +10,12 @@ import { ButtonStyle } from "widgets/ButtonWidget";
import { Theme, darkenHover, darkenActive } from "constants/DefaultTheme";
import _ from "lodash";
import { ComponentProps } from "components/designSystems/appsmith/BaseComponent";
import useScript from "utils/hooks/useScript";
import { AppToaster } from "components/editorComponents/ToastComponent";
import {
GOOGLE_RECAPTCHA_KEY_ERROR,
GOOGLE_RECAPTCHA_DOMAIN_ERROR,
} from "constants/messages";
const getButtonColorStyles = (props: { theme: Theme } & ButtonStyleProps) => {
if (props.filled) return props.theme.colors.textOnDarkBG;
@ -124,6 +130,11 @@ export enum ButtonType {
BUTTON = "button",
}
interface RecaptchaProps {
googleRecaptchaKey?: string;
clickWithRecaptcha: (token: string) => void;
}
interface ButtonContainerProps extends ComponentProps {
text?: string;
icon?: MaybeElement;
@ -148,20 +159,82 @@ const mapButtonStyleToStyleName = (buttonStyle?: ButtonStyle) => {
}
};
// To be used with the canvas
const ButtonContainer = (props: ButtonContainerProps & ButtonStyleProps) => {
const RecaptchaComponent = (
props: {
children: any;
onClick?: (event: React.MouseEvent<HTMLElement>) => void;
} & RecaptchaProps,
) => {
function handleError(event: React.MouseEvent<HTMLElement>, error: string) {
AppToaster.show({
message: error,
type: "error",
});
props.onClick && props.onClick(event);
}
const status = useScript(
`https://www.google.com/recaptcha/api.js?render=${props.googleRecaptchaKey}`,
);
return (
<BaseButton
loading={props.isLoading}
icon={props.icon}
rightIcon={props.rightIcon}
text={props.text}
filled={props.buttonStyle !== "SECONDARY_BUTTON"}
accent={mapButtonStyleToStyleName(props.buttonStyle)}
<div
onClick={(event: React.MouseEvent<HTMLElement>) => {
if (status === "ready") {
(window as any).grecaptcha.ready(() => {
try {
(window as any).grecaptcha
.execute(props.googleRecaptchaKey, { action: "submit" })
.then((token: any) => {
props.clickWithRecaptcha(token);
})
.catch(() => {
// Handle corrent key with wrong
handleError(event, GOOGLE_RECAPTCHA_KEY_ERROR);
});
} catch (ex) {
// Handle wrong key
handleError(event, GOOGLE_RECAPTCHA_DOMAIN_ERROR);
}
});
}
}}
>
{props.children}
</div>
);
};
const BtnWrapper = (
props: {
children: any;
onClick?: (event: React.MouseEvent<HTMLElement>) => void;
} & RecaptchaProps,
) => {
if (!props.googleRecaptchaKey)
return <div onClick={props.onClick}>{props.children}</div>;
return <RecaptchaComponent {...props}></RecaptchaComponent>;
};
// To be used with the canvas
const ButtonContainer = (
props: ButtonContainerProps & ButtonStyleProps & RecaptchaProps,
) => {
return (
<BtnWrapper
googleRecaptchaKey={props.googleRecaptchaKey}
clickWithRecaptcha={props.clickWithRecaptcha}
onClick={props.onClick}
disabled={props.disabled}
type={props.type}
/>
>
<BaseButton
loading={props.isLoading}
icon={props.icon}
rightIcon={props.rightIcon}
text={props.text}
filled={props.buttonStyle !== "SECONDARY_BUTTON"}
accent={mapButtonStyleToStyleName(props.buttonStyle)}
disabled={props.disabled}
type={props.type}
/>
</BtnWrapper>
);
};

View File

@ -163,3 +163,7 @@ export const TABLE_FILTER_COLUMN_TYPE_CALLOUT =
export const WIDGET_SIDEBAR_TITLE = "Widgets";
export const WIDGET_SIDEBAR_CAPTION =
"To add a widget, please drag and drop a widget on the canvas to the right";
export const GOOGLE_RECAPTCHA_KEY_ERROR =
"Google Re-Captcha Token Generation failed! Please check the Re-captcha Site Key.";
export const GOOGLE_RECAPTCHA_DOMAIN_ERROR =
"Google Re-Captcha Token Generation failed! Please check the allowed domains.";

View File

@ -806,6 +806,14 @@ const PropertyPaneConfigResponse: PropertyPaneConfigsResponse["data"] = {
controlType: "SWITCH",
isJSConvertible: true,
},
{
id: "15.1.6",
propertyName: "googleRecaptchaKey",
label: "Google Recaptcha Key",
helpText: "Sets Google Recaptcha v3 site key for button",
controlType: "INPUT_TEXT",
placeholderText: "Enter google recaptcha key",
},
],
},
{
@ -954,6 +962,14 @@ const PropertyPaneConfigResponse: PropertyPaneConfigsResponse["data"] = {
helpText: "Disables clicks to this widget",
isJSConvertible: true,
},
{
id: "1.1.4",
propertyName: "googleRecaptchaKey",
label: "Google Recaptcha Key",
helpText: "Sets Google Recaptcha v3 site key for button",
controlType: "INPUT_TEXT",
placeholderText: "Enter google recaptcha key",
},
],
},
{

View File

@ -113,6 +113,8 @@ export const entityDefinitions = {
isVisible: isVisible,
text: "string",
isDisabled: "bool",
recaptchaToken: "string",
googleRecaptchaKey: "string",
},
DATE_PICKER_WIDGET: {
"!doc":
@ -176,6 +178,8 @@ export const entityDefinitions = {
isVisible: isVisible,
text: "string",
isDisabled: "bool",
recaptchaToken: "string",
googleRecaptchaKey: "string",
},
MAP_WIDGET: {
isVisible: isVisible,

View File

@ -0,0 +1,69 @@
import { useState, useEffect } from "react";
// Hook
export default function useScript(src: string) {
// Keep track of script status ("idle", "loading", "ready", "error")
const [status, setStatus] = useState(src ? "loading" : "idle");
useEffect(
() => {
// Allow falsy src value if waiting on other data needed for
// constructing the script URL passed to this hook.
if (!src) {
setStatus("idle");
return;
}
// Fetch existing script element by src
// It may have been added by another intance of this hook
let script = document.querySelector(`script[src="${src}"]`) as any;
if (!script) {
// Create script
script = document.createElement("script");
script.src = src;
script.async = true;
script.setAttribute("data-status", "loading");
// Add script to document body
document.body.appendChild(script);
// Store status in attribute on script
// This can be read by other instances of this hook
const setAttributeFromEvent = (event: any) => {
script.setAttribute(
"data-status",
event.type === "load" ? "ready" : "error",
);
};
script.addEventListener("load", setAttributeFromEvent);
script.addEventListener("error", setAttributeFromEvent);
} else {
// Grab existing script status from attribute and set to state.
setStatus(script.getAttribute("data-status"));
}
// Script event handler to update status in state
// Note: Even if the script already exists we still need to add
// event handlers to update the state for *this* hook instance.
const setStateFromEvent = (event: any) => {
setStatus(event.type === "load" ? "ready" : "error");
};
// Add event listeners
script.addEventListener("load", setStateFromEvent);
script.addEventListener("error", setStateFromEvent);
// Remove event listeners on cleanup
return () => {
if (script) {
script.removeEventListener("load", setStateFromEvent);
script.removeEventListener("error", setStateFromEvent);
}
};
},
[src], // Only re-run effect if script src changes
);
return status;
}

View File

@ -12,13 +12,15 @@ import {
import { VALIDATION_TYPES } from "constants/WidgetValidation";
import { TriggerPropertiesMap } from "utils/WidgetFactory";
import * as Sentry from "@sentry/react";
import withMeta, { WithMeta } from "./MetaHOC";
class ButtonWidget extends BaseWidget<ButtonWidgetProps, ButtonWidgetState> {
onButtonClickBound: (event: React.MouseEvent<HTMLElement>) => void;
clickWithRecaptchaBound: (token: string) => void;
constructor(props: ButtonWidgetProps) {
super(props);
this.onButtonClickBound = this.onButtonClick.bind(this);
this.clickWithRecaptchaBound = this.clickWithRecaptcha.bind(this);
this.state = {
isLoading: false,
};
@ -38,6 +40,11 @@ class ButtonWidget extends BaseWidget<ButtonWidgetProps, ButtonWidgetState> {
onClick: true,
};
}
static getMetaPropertiesMap(): Record<string, any> {
return {
recaptchaToken: undefined,
};
}
onButtonClick() {
if (this.props.onClick) {
@ -54,6 +61,21 @@ class ButtonWidget extends BaseWidget<ButtonWidgetProps, ButtonWidgetState> {
}
}
clickWithRecaptcha(token: string) {
if (this.props.onClick) {
this.setState({
isLoading: true,
});
}
this.props.updateWidgetMetaProperty("recaptchaToken", token, {
dynamicString: this.props.onClick,
event: {
type: EventType.ON_CLICK,
callback: this.handleActionComplete,
},
});
}
handleActionComplete = () => {
this.setState({
isLoading: false,
@ -72,6 +94,8 @@ class ButtonWidget extends BaseWidget<ButtonWidgetProps, ButtonWidgetState> {
onClick={this.onButtonClickBound}
isLoading={this.props.isLoading || this.state.isLoading}
type={this.props.buttonType || ButtonType.BUTTON}
googleRecaptchaKey={this.props.googleRecaptchaKey}
clickWithRecaptcha={this.clickWithRecaptchaBound}
/>
);
}
@ -87,13 +111,14 @@ export type ButtonStyle =
| "SUCCESS_BUTTON"
| "DANGER_BUTTON";
export interface ButtonWidgetProps extends WidgetProps {
export interface ButtonWidgetProps extends WidgetProps, WithMeta {
text?: string;
buttonStyle?: ButtonStyle;
onClick?: string;
isDisabled?: boolean;
isVisible?: boolean;
buttonType?: ButtonType;
googleRecaptchaKey?: string;
}
interface ButtonWidgetState extends WidgetState {
@ -101,4 +126,4 @@ interface ButtonWidgetState extends WidgetState {
}
export default ButtonWidget;
export const ProfiledButtonWidget = Sentry.withProfiler(ButtonWidget);
export const ProfiledButtonWidget = Sentry.withProfiler(withMeta(ButtonWidget));

View File

@ -12,21 +12,30 @@ import {
import { VALIDATION_TYPES } from "constants/WidgetValidation";
import { TriggerPropertiesMap } from "utils/WidgetFactory";
import * as Sentry from "@sentry/react";
import withMeta, { WithMeta } from "./MetaHOC";
class FormButtonWidget extends BaseWidget<
FormButtonWidgetProps,
FormButtonWidgetState
> {
onButtonClickBound: (event: React.MouseEvent<HTMLElement>) => void;
clickWithRecaptchaBound: (token: string) => void;
constructor(props: FormButtonWidgetProps) {
super(props);
this.onButtonClickBound = this.onButtonClick.bind(this);
this.clickWithRecaptchaBound = this.clickWithRecaptcha.bind(this);
this.state = {
isLoading: false,
};
}
static getMetaPropertiesMap(): Record<string, any> {
return {
recaptchaToken: undefined,
};
}
static getPropertyValidationMap(): WidgetPropertyValidationType {
return {
...BASE_WIDGET_VALIDATION,
@ -44,6 +53,21 @@ class FormButtonWidget extends BaseWidget<
};
}
clickWithRecaptcha(token: string) {
if (this.props.onClick) {
this.setState({
isLoading: true,
});
}
this.props.updateWidgetMetaProperty("recaptchaToken", token, {
dynamicString: this.props.onClick,
event: {
type: EventType.ON_CLICK,
callback: this.handleActionResult,
},
});
}
onButtonClick() {
if (this.props.onClick) {
this.setState({
@ -88,6 +112,8 @@ class FormButtonWidget extends BaseWidget<
onClick={this.onButtonClickBound}
isLoading={this.props.isLoading || this.state.isLoading}
type={this.props.buttonType || ButtonType.BUTTON}
googleRecaptchaKey={this.props.googleRecaptchaKey}
clickWithRecaptcha={this.clickWithRecaptchaBound}
/>
);
}
@ -103,7 +129,7 @@ export type ButtonStyle =
| "SUCCESS_BUTTON"
| "DANGER_BUTTON";
export interface FormButtonWidgetProps extends WidgetProps {
export interface FormButtonWidgetProps extends WidgetProps, WithMeta {
text?: string;
buttonStyle?: ButtonStyle;
onClick?: string;
@ -113,6 +139,7 @@ export interface FormButtonWidgetProps extends WidgetProps {
resetFormOnClick?: boolean;
onReset?: () => void;
disabledWhenInvalid?: boolean;
googleRecaptchaKey?: string;
}
export interface FormButtonWidgetState extends WidgetState {
@ -120,4 +147,6 @@ export interface FormButtonWidgetState extends WidgetState {
}
export default FormButtonWidget;
export const ProfiledFormButtonWidget = Sentry.withProfiler(FormButtonWidget);
export const ProfiledFormButtonWidget = Sentry.withProfiler(
withMeta(FormButtonWidget),
);