diff --git a/for-devs.md b/for-devs.md new file mode 100644 index 000000000..212f99f6f --- /dev/null +++ b/for-devs.md @@ -0,0 +1,78 @@ +## Developer Information + +This file is intended for developers. It provides details on the project's structure and instructions on how to work +with it. + +### 1. Commands + +* Installation: `npm install` +* Building packages: `npm run build:all` +* Building the bundle: `npm run build:bundle` +* Linting: `npm run lint-fix` (formatting & code validation) + +### 2. Environment Setup for Tests (Once) + +To set up the environment for testing, run the following commands: + +``` +cp demos/client-example-server/env.development demos/client-example-server/.env.test +cp demos/client-example/env.development demos/client-example/.env.test +cp demos/client-bundle-example/env.development demos/client-bundle-example/.env.test +cp dev/scripts/env.test .env.test +cp dev/scripts/env.test dev/scripts/.env.test +cp dev/scripts/env.test packages/cli/.env.test +cp dev/scripts/env.test packages/procaptcha-bundle/.env.test +``` + +### 3. Running E2E Client Tests Locally + +#### 3.1) Launching services + +The DB is docked, and to start the DB service, run the following: + +``` +docker compose --file ./docker/docker-compose.test.yml up -d --remove-orphans --force-recreate --always-recreate-deps +NODE_ENV="test" npm run setup +``` + +> Note: the second command should be called once per the container lifetime, and adds the initial data, like domains, +> siteKeys, etc. + +Then start the services: + +``` +npm run -w @prosopo/client-example-server build && NODE_ENV=test npm run start:server +NODE_ENV=test npm run start:demo +NODE_ENV=test npm run start:provider:admin +``` + +#### 3.2) Running the Tests + +``` +NODE_ENV=test npm run -w @prosopo/cypress-shared cypress:open:client-example +``` + +#### 3.3) Stopping Docker Services + +After the tests finish, stop the Docker services with: + +``` +docker compose --file ./docker/docker-compose.test.yml down +``` + +### 4. Running E2E Bundle Tests Locally + +#### 4.1) Launching Services + +For bundle tests, use the same services as for the E2E client tests, plus the following: + +``` +NODE_ENV="development" npm -w @prosopo/procaptcha-bundle run bundle +NODE_ENV=test npm run start:bundle +``` + +#### 4.2) Running the Tests + +``` +NODE_ENV=test npm -w @prosopo/cypress-shared run cypress:open:client-bundle-example +``` diff --git a/packages/procaptcha-bundle/src/util/defaultCallbacks.ts b/packages/procaptcha-bundle/src/util/defaultCallbacks.ts index 01cd8ebed..8bc13474d 100644 --- a/packages/procaptcha-bundle/src/util/defaultCallbacks.ts +++ b/packages/procaptcha-bundle/src/util/defaultCallbacks.ts @@ -29,7 +29,18 @@ export const getWindowCallback = (callbackName: string) => { return fn; }; -export const getDefaultCallbacks = (element: Element) => ({ +export interface Callbacks { + onHuman: (token: ProcaptchaToken) => void; + onChallengeExpired: () => void; + onExpired: () => void; + onError: (error: Error) => void; + onClose: () => void; + onOpen: () => void; + onFailed: () => void; + onReset: () => void; +} + +export const getDefaultCallbacks = (element: Element): Callbacks => ({ onHuman: (token: ProcaptchaToken) => handleOnHuman(element, token), onChallengeExpired: () => { removeProcaptchaResponse(); @@ -60,16 +71,7 @@ export const getDefaultCallbacks = (element: Element) => ({ export function setUserCallbacks( renderOptions: ProcaptchaRenderOptions | undefined, - callbacks: { - onHuman: (token: ProcaptchaToken) => void; - onChallengeExpired: () => void; - onExpired: () => void; - onError: (error: Error) => void; - onClose: () => void; - onOpen: () => void; - onFailed: () => void; - onReset: () => void; - }, + callbacks: Callbacks, element: Element, ) { if (typeof renderOptions?.callback === "function") { diff --git a/packages/procaptcha-bundle/src/util/renderLogic.tsx b/packages/procaptcha-bundle/src/util/renderLogic.tsx index 07feac6ab..88bbfd2fa 100644 --- a/packages/procaptcha-bundle/src/util/renderLogic.tsx +++ b/packages/procaptcha-bundle/src/util/renderLogic.tsx @@ -1,5 +1,3 @@ -import createCache from "@emotion/cache"; -import { CacheProvider } from "@emotion/react"; // Copyright 2021-2024 Prosopo (UK) Ltd. // // Licensed under the Apache License, Version 2.0 (the "License"); @@ -13,94 +11,34 @@ import { CacheProvider } from "@emotion/react"; // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. -import { ProcaptchaFrictionless } from "@prosopo/procaptcha-frictionless"; -import { ProcaptchaPow } from "@prosopo/procaptcha-pow"; -import { Procaptcha } from "@prosopo/procaptcha-react"; -import type { - ProcaptchaClientConfigOutput, - ProcaptchaRenderOptions, +import { + FeaturesEnum, + type ProcaptchaClientConfigOutput, + type ProcaptchaRenderOptions, } from "@prosopo/types"; -import { type Root, createRoot } from "react-dom/client"; -import { getDefaultCallbacks, setUserCallbacks } from "./defaultCallbacks.js"; -import { setLanguage } from "./language.js"; -import { setTheme } from "./theme.js"; -import { setValidChallengeLength } from "./timeout.js"; +import { CaptchaRenderer } from "./renderLogic/captcha/captchaRenderer.js"; +import { WebComponent } from "./renderLogic/webComponent.js"; +import { WidgetRenderer } from "./renderLogic/widgetRenderer.js"; -const identifierPrefix = "procaptcha-"; - -function makeShadowRoot( - element: Element, - renderOptions?: ProcaptchaRenderOptions, -): ShadowRoot { - // todo maybe introduce customCSS in renderOptions. - const customCss = ""; - - const wrapperElement = document.createElement("prosopo-procaptcha"); - - const wrapperShadow = wrapperElement.attachShadow({ mode: "open" }); - wrapperShadow.innerHTML += - ''; - wrapperShadow.innerHTML += - "" !== customCss ? `` : ""; - - element.appendChild(wrapperElement); - - return wrapperShadow; -} +const widgetRenderer = new WidgetRenderer( + new WebComponent(), + new CaptchaRenderer(), +); export const renderLogic = ( elements: Element[], config: ProcaptchaClientConfigOutput, renderOptions?: ProcaptchaRenderOptions, ) => { - const roots: Root[] = []; - - for (const element of elements) { - const callbacks = getDefaultCallbacks(element); - const shadowRoot = makeShadowRoot(element, renderOptions); - - setUserCallbacks(renderOptions, callbacks, element); - setTheme(renderOptions, element, config); - setValidChallengeLength(renderOptions, element, config); - setLanguage(renderOptions, element, config); - - const emotionCache = createCache({ - key: "procaptcha", - prepend: true, - container: shadowRoot, - }); - - let root: Root | null = null; - switch (renderOptions?.captchaType) { - case "pow": - console.log("rendering pow"); - root = createRoot(shadowRoot, { identifierPrefix }); - root.render( - - - , - ); - break; - case "image": - console.log("rendering image"); - root = createRoot(shadowRoot, { identifierPrefix }); - root.render( - - - , - ); - break; - default: - console.log("rendering frictionless"); - root = createRoot(shadowRoot, { identifierPrefix }); - root.render( - - - , - ); - break; - } - roots.push(root); - } - return roots; + return widgetRenderer.renderElements( + { + identifierPrefix: "procaptcha-", + emotionCacheKey: "procaptcha", + webComponentTag: "prosopo-procaptcha", + defaultCaptchaType: FeaturesEnum.Frictionless, + }, + elements, + config, + renderOptions, + ); }; diff --git a/packages/procaptcha-bundle/src/util/renderLogic/captcha/captcha.ts b/packages/procaptcha-bundle/src/util/renderLogic/captcha/captcha.ts new file mode 100644 index 000000000..7ef95c7ed --- /dev/null +++ b/packages/procaptcha-bundle/src/util/renderLogic/captcha/captcha.ts @@ -0,0 +1,25 @@ +// Copyright 2021-2024 Prosopo (UK) Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +import type { ProcaptchaClientConfigOutput } from "@prosopo/types"; +import React from "react"; +import type { Callbacks } from "../../defaultCallbacks.js"; + +interface CaptchaProps { + config: ProcaptchaClientConfigOutput; + callbacks: Callbacks; +} + +abstract class CaptchaElement extends React.Component {} + +export { type CaptchaProps, CaptchaElement }; diff --git a/packages/procaptcha-bundle/src/util/renderLogic/captcha/captchaRenderer.tsx b/packages/procaptcha-bundle/src/util/renderLogic/captcha/captchaRenderer.tsx new file mode 100644 index 000000000..9f4fc7d68 --- /dev/null +++ b/packages/procaptcha-bundle/src/util/renderLogic/captcha/captchaRenderer.tsx @@ -0,0 +1,37 @@ +// Copyright 2021-2024 Prosopo (UK) Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +import type { FeaturesEnum } from "@prosopo/types"; +import type { ReactNode } from "react"; +import type { CaptchaProps } from "./captcha.js"; +import { componentsList } from "./componentsList.js"; + +class CaptchaRenderer { + public render( + captchaType: FeaturesEnum, + captchaProps: CaptchaProps, + ): ReactNode { + const CaptchaComponent = componentsList[captchaType]; + + console.log(`rendering ${captchaType}`); + + return ( + + ); + } +} + +export { CaptchaRenderer }; diff --git a/packages/procaptcha-bundle/src/util/renderLogic/captcha/components/frictionlessCaptcha.tsx b/packages/procaptcha-bundle/src/util/renderLogic/captcha/components/frictionlessCaptcha.tsx new file mode 100644 index 000000000..bcac86d78 --- /dev/null +++ b/packages/procaptcha-bundle/src/util/renderLogic/captcha/components/frictionlessCaptcha.tsx @@ -0,0 +1,26 @@ +// Copyright 2021-2024 Prosopo (UK) Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +import { ProcaptchaFrictionless } from "@prosopo/procaptcha-frictionless"; +import React from "react"; +import { CaptchaElement } from "../captcha.js"; + +class FrictionlessCaptcha extends CaptchaElement { + public override render() { + const { config, callbacks } = this.props; + + return ; + } +} + +export { FrictionlessCaptcha }; diff --git a/packages/procaptcha-bundle/src/util/renderLogic/captcha/components/imageCaptcha.tsx b/packages/procaptcha-bundle/src/util/renderLogic/captcha/components/imageCaptcha.tsx new file mode 100644 index 000000000..4acbdea04 --- /dev/null +++ b/packages/procaptcha-bundle/src/util/renderLogic/captcha/components/imageCaptcha.tsx @@ -0,0 +1,26 @@ +// Copyright 2021-2024 Prosopo (UK) Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +import { Procaptcha } from "@prosopo/procaptcha-react"; +import React from "react"; +import { CaptchaElement } from "../captcha.js"; + +class ImageCaptcha extends CaptchaElement { + public override render() { + const { config, callbacks } = this.props; + + return ; + } +} + +export { ImageCaptcha }; diff --git a/packages/procaptcha-bundle/src/util/renderLogic/captcha/components/powCaptcha.tsx b/packages/procaptcha-bundle/src/util/renderLogic/captcha/components/powCaptcha.tsx new file mode 100644 index 000000000..4b626e72d --- /dev/null +++ b/packages/procaptcha-bundle/src/util/renderLogic/captcha/components/powCaptcha.tsx @@ -0,0 +1,26 @@ +// Copyright 2021-2024 Prosopo (UK) Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +import { ProcaptchaPow } from "@prosopo/procaptcha-pow"; +import React from "react"; +import { CaptchaElement } from "../captcha.js"; + +class PowCaptcha extends CaptchaElement { + public override render() { + const { config, callbacks } = this.props; + + return ; + } +} + +export { PowCaptcha }; diff --git a/packages/procaptcha-bundle/src/util/renderLogic/captcha/componentsList.ts b/packages/procaptcha-bundle/src/util/renderLogic/captcha/componentsList.ts new file mode 100644 index 000000000..364cc3405 --- /dev/null +++ b/packages/procaptcha-bundle/src/util/renderLogic/captcha/componentsList.ts @@ -0,0 +1,30 @@ +// Copyright 2021-2024 Prosopo (UK) Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +import { FeaturesEnum } from "@prosopo/types"; +import type React from "react"; +import type { CaptchaProps } from "./captcha.js"; +import { FrictionlessCaptcha } from "./components/frictionlessCaptcha.js"; +import { ImageCaptcha } from "./components/imageCaptcha.js"; +import { PowCaptcha } from "./components/powCaptcha.js"; + +const componentsList: Record< + FeaturesEnum, + React.ComponentType +> = { + [FeaturesEnum.Image]: ImageCaptcha, + [FeaturesEnum.Pow]: PowCaptcha, + [FeaturesEnum.Frictionless]: FrictionlessCaptcha, +}; + +export { componentsList }; diff --git a/packages/procaptcha-bundle/src/util/renderLogic/webComponent.ts b/packages/procaptcha-bundle/src/util/renderLogic/webComponent.ts new file mode 100644 index 000000000..61ef8d2cd --- /dev/null +++ b/packages/procaptcha-bundle/src/util/renderLogic/webComponent.ts @@ -0,0 +1,48 @@ +// Copyright 2021-2024 Prosopo (UK) Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +class WebComponent { + public addToElement(componentTag: string, element: Element): ShadowRoot { + const webComponent = this.makeWebComponent(componentTag); + const shadowRoot = this.attachShadowDom(webComponent); + + element.appendChild(webComponent); + + return shadowRoot; + } + + protected makeWebComponent(componentTag: string): HTMLElement { + return document.createElement(componentTag); + } + + protected getBaseShadowStyles(): string { + // todo maybe introduce customCSS in renderOptions. + const customCss = ""; + + let baseStyles = + ''; + baseStyles += "" !== customCss ? `` : ""; + + return baseStyles; + } + + protected attachShadowDom(webComponent: HTMLElement): ShadowRoot { + const shadowRoot = webComponent.attachShadow({ mode: "open" }); + + shadowRoot.innerHTML += this.getBaseShadowStyles(); + + return shadowRoot; + } +} + +export { WebComponent }; diff --git a/packages/procaptcha-bundle/src/util/renderLogic/widgetRenderer.tsx b/packages/procaptcha-bundle/src/util/renderLogic/widgetRenderer.tsx new file mode 100644 index 000000000..248eca594 --- /dev/null +++ b/packages/procaptcha-bundle/src/util/renderLogic/widgetRenderer.tsx @@ -0,0 +1,123 @@ +// Copyright 2021-2024 Prosopo (UK) Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +import createCache, { type EmotionCache } from "@emotion/cache"; +import { CacheProvider } from "@emotion/react"; +import type { + ProcaptchaClientConfigOutput, + ProcaptchaRenderOptions, +} from "@prosopo/types"; +import type { FeaturesEnum } from "@prosopo/types"; +import { type Root, createRoot } from "react-dom/client"; +import { + type Callbacks, + getDefaultCallbacks, + setUserCallbacks, +} from "../defaultCallbacks.js"; +import { setLanguage } from "../language.js"; +import { setTheme } from "../theme.js"; +import { setValidChallengeLength } from "../timeout.js"; +import type { CaptchaRenderer } from "./captcha/captchaRenderer.js"; +import type { WebComponent } from "./webComponent.js"; + +interface RenderSettings { + identifierPrefix: string; + emotionCacheKey: string; + webComponentTag: string; + defaultCaptchaType: FeaturesEnum; +} + +class WidgetRenderer { + private readonly webComponent: WebComponent; + private readonly captchaRenderer: CaptchaRenderer; + + constructor(webComponent: WebComponent, captchaRenderer: CaptchaRenderer) { + this.webComponent = webComponent; + this.captchaRenderer = captchaRenderer; + } + + public renderElements( + settings: RenderSettings, + elements: Element[], + config: ProcaptchaClientConfigOutput, + renderOptions?: ProcaptchaRenderOptions, + ): Root[] { + return elements.map((element) => { + return this.renderElement(settings, element, config, renderOptions); + }); + } + + protected renderElement( + settings: RenderSettings, + element: Element, + config: ProcaptchaClientConfigOutput, + renderOptions?: ProcaptchaRenderOptions, + ): Root { + const captchaType = + (renderOptions?.captchaType as FeaturesEnum) || + settings.defaultCaptchaType; + const callbacks = getDefaultCallbacks(element); + + this.readAndValidateSettings(element, callbacks, config, renderOptions); + + // Clear all the children inside, if there are any. + // If the renderElement() is called several times on the same element, it should recreate the captcha from scratch. + element.innerHTML = ""; + + const shadowRoot = this.webComponent.addToElement( + settings.webComponentTag, + element, + ); + const emotionCache = this.makeEmotionCache( + settings.emotionCacheKey, + shadowRoot, + ); + const root = createRoot(shadowRoot, { + identifierPrefix: settings.identifierPrefix, + }); + + const captcha = this.captchaRenderer.render(captchaType, { + config: config, + callbacks: callbacks, + }); + + root.render({captcha}); + + return root; + } + + protected readAndValidateSettings( + element: Element, + callbacks: Callbacks, + config: ProcaptchaClientConfigOutput, + renderOptions?: ProcaptchaRenderOptions, + ): void { + setUserCallbacks(renderOptions, callbacks, element); + setTheme(renderOptions, element, config); + setValidChallengeLength(renderOptions, element, config); + setLanguage(renderOptions, element, config); + } + + protected makeEmotionCache( + cacheKey: string, + shadowRoot: ShadowRoot, + ): EmotionCache { + return createCache({ + key: cacheKey, + prepend: true, + container: shadowRoot, + }); + } +} + +export { WidgetRenderer };