0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-04-08 02:52:39 -05:00

Refactored Captcha to simplify usage

ref BAE-397

Moved the hCaptcha component outside of the page level, since it was
complicating the logic within the pages with no good reason.

The hCaptcha component is now attached to the pop-up modal. Since it's
invisible, this doesn't impact layout anyway, but means that any action
can trigger Captcha to run, and use the result within that same action.

This simplifies the flow by having the action itself confirm that
Captcha is enabled, then grabbing the token by running either a
challenge (for self-hosters) or using their enterprise heuristics system
(for Ghost Pro).

This also fixes issues where sites with multiple tiers wouldn't work
with Captcha, since the page had changed and the hCaptcha component was
unloaded.
This commit is contained in:
Sam Lord 2025-02-17 08:17:58 +00:00 committed by GitHub
parent 13c5c5ff17
commit 2740686d53
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 177 additions and 127 deletions

View file

@ -3,9 +3,14 @@ import React from 'react';
function HCaptchaMockBase({onLoad, onVerify, ...props}, ref) {
// Provide mock execute method
React.useImperativeHandle(ref, () => ({
execute: () => {
// Simulate successful CAPTCHA token
execute: (options) => {
onVerify?.('mocked-token');
if (options.async) {
return Promise.resolve({
response: 'mocked-token'
});
}
}
}));

View file

@ -23,7 +23,8 @@ const DEV_MODE_DATA = {
member: Fixtures.member.free,
page: 'accountEmail',
...Fixtures.paidMemberOnTier(),
pageData: Fixtures.offer
pageData: Fixtures.offer,
captchaRef: React.createRef()
};
function SentryErrorBoundary({site, children}) {
@ -58,7 +59,8 @@ export default class App extends React.Component {
lastPage: null,
customSiteUrl: props.customSiteUrl,
locale: props.locale,
scrollbarWidth: 0
scrollbarWidth: 0,
captchaRef: React.createRef()
};
}
@ -958,7 +960,7 @@ export default class App extends React.Component {
/**Get final App level context from App state*/
getContextFromState() {
const {site, member, action, page, lastPage, showPopup, pageQuery, pageData, popupNotification, customSiteUrl, t, dir, scrollbarWidth} = this.state;
const {site, member, action, page, lastPage, showPopup, pageQuery, pageData, popupNotification, customSiteUrl, t, dir, scrollbarWidth, captchaRef} = this.state;
const contextPage = this.getContextPage({site, page, member});
const contextMember = this.getContextMember({page: contextPage, member, customSiteUrl});
return {
@ -977,6 +979,7 @@ export default class App extends React.Component {
t,
dir,
scrollbarWidth,
captchaRef,
onAction: (_action, data) => this.dispatchAction(_action, data)
};
}

View file

@ -1,6 +1,6 @@
import setupGhostApi from './utils/api';
import {chooseBestErrorMessage} from './utils/errors';
import {createPopupNotification, getMemberEmail, getMemberName, getProductCadenceFromPrice, removePortalLinkFromUrl, getRefDomain} from './utils/helpers';
import {createPopupNotification, getMemberEmail, getMemberName, getProductCadenceFromPrice, removePortalLinkFromUrl, getRefDomain, hasCaptchaEnabled} from './utils/helpers';
function switchPage({data, state}) {
return {
@ -79,8 +79,14 @@ async function signout({api, state}) {
}
async function signin({data, api, state}) {
const {t} = state;
const {captchaRef, site, t} = state;
try {
if (hasCaptchaEnabled({site})) {
const {response} = await captchaRef.current.execute({async: true});
data.token = response;
}
const integrityToken = await api.member.getIntegrityToken();
await api.member.sendMagicLink({...data, emailType: 'signin', integrityToken});
return {
@ -100,6 +106,12 @@ async function signin({data, api, state}) {
async function signup({data, state, api}) {
try {
if (hasCaptchaEnabled({site: state.site})) {
const {captchaRef} = state;
const {response} = await captchaRef.current.execute({async: true});
data.token = response;
}
let {plan, tierId, cadence, email, name, newsletters, offerId} = data;
if (plan.toLowerCase() === 'free') {

View file

@ -6,7 +6,8 @@ import {getFrameStyles} from './Frame.styles';
import Pages, {getActivePage} from '../pages';
import PopupNotification from './common/PopupNotification';
import PoweredBy from './common/PoweredBy';
import {getSiteProducts, hasAvailablePrices, isInviteOnly, isCookiesDisabled, hasFreeProductPrice} from '../utils/helpers';
import {getSiteProducts, hasAvailablePrices, isInviteOnly, isCookiesDisabled, hasFreeProductPrice, hasCaptchaEnabled, getCaptchaSitekey} from '../utils/helpers';
import HCaptcha from '@hcaptcha/react-hcaptcha';
const StylesWrapper = () => {
return {
@ -131,6 +132,25 @@ class PopupContent extends React.Component {
);
}
renderHCaptcha() {
const {site, captchaRef} = this.context;
if (hasCaptchaEnabled({site})) {
return (
<HCaptcha
size="invisible"
sitekey={getCaptchaSitekey({site})}
onVerify={token => this.context.onAction('verifyCaptcha', {token})}
onError={error => this.context.onAction('captchaError', {error})}
ref={captchaRef}
id="hcaptcha-portal"
/>
);
} else {
return null;
}
}
sendPortalPreviewReadyEvent() {
if (window.self !== window.parent) {
window.parent.postMessage({
@ -216,6 +236,7 @@ class PopupContent extends React.Component {
<>
<div className={'gh-portal-popup-wrapper ' + pageClass} onClick={e => this.handlePopupClose(e)}>
{this.renderPopupNotification()}
{this.renderHCaptcha()}
<div className={containerClassName} style={pageStyle} ref={node => (this.node = node)} tabIndex={-1}>
<CookieDisabledBanner message={cookieBannerText} />
{this.renderActivePage()}

View file

@ -5,9 +5,8 @@ import CloseButton from '../common/CloseButton';
import AppContext from '../../AppContext';
import InputForm from '../common/InputForm';
import {ValidateInputForm} from '../../utils/form';
import {hasAvailablePrices, isSigninAllowed, isSignupAllowed, hasCaptchaEnabled, getCaptchaSitekey} from '../../utils/helpers';
import {hasAvailablePrices, isSigninAllowed, isSignupAllowed} from '../../utils/helpers';
import {ReactComponent as InvitationIcon} from '../../images/icons/invitation.svg';
import HCaptcha from '@hcaptcha/react-hcaptcha';
export default class SigninPage extends React.Component {
static contextType = AppContext;
@ -34,14 +33,7 @@ export default class SigninPage extends React.Component {
handleSignin(e) {
e.preventDefault();
const {site} = this.context;
if (hasCaptchaEnabled({site})) {
// hCaptcha's callback will call doSignin
return this.captchaRef.current.execute();
} else {
this.doSignin();
}
this.doSignin();
}
doSignin() {
@ -172,15 +164,6 @@ export default class SigninPage extends React.Component {
onChange={(e, field) => this.handleInputChange(e, field)}
onKeyDown={(e, field) => this.onKeyDown(e, field)}
/>
{(hasCaptchaEnabled({site}) &&
<HCaptcha
size="invisible"
sitekey={getCaptchaSitekey({site})}
onLoad={() => this.setState({captchaLoaded: true})}
onVerify={token => this.setState({token: token}, this.doSignin)}
ref={this.captchaRef}
/>
)}
</div>
<footer className='gh-portal-signin-footer'>
{this.renderSubmitButton()}

View file

@ -1,10 +1,7 @@
import {vi} from 'vitest';
import {render, fireEvent, getByTestId} from '../../utils/test-utils';
import SigninPage from './SigninPage';
import {getSiteData} from '../../utils/fixtures-generator';
vi.mock('@hcaptcha/react-hcaptcha');
const setup = (overrides) => {
const {mockOnActionFn, ...utils} = render(
<SigninPage />,
@ -75,33 +72,4 @@ describe('SigninPage', () => {
expect(message).toBeInTheDocument();
});
});
describe('when captcha is enabled', () => {
test('renders captcha', () => {
setup({
site: getSiteData({
captchaEnabled: true,
captchaSiteKey: '20000000-ffff-ffff-ffff-000000000002'
})
});
const hcaptchaElement = getByTestId(document.body, 'hcaptcha-mock');
expect(hcaptchaElement).toBeInTheDocument();
});
test('uses Captcha when run', () => {
const {emailInput, submitButton, mockOnActionFn} = setup({
site: getSiteData({
captchaEnabled: true,
captchaSiteKey: '20000000-ffff-ffff-ffff-000000000002'
})
});
fireEvent.change(emailInput, {target: {value: 'member@example.com'}});
expect(emailInput).toHaveValue('member@example.com');
fireEvent.click(submitButton);
expect(mockOnActionFn).toHaveBeenCalledWith('signin', {email: 'member@example.com', token: 'mocked-token'});
});
});
});

View file

@ -7,10 +7,9 @@ import NewsletterSelectionPage from './NewsletterSelectionPage';
import ProductsSection from '../common/ProductsSection';
import InputForm from '../common/InputForm';
import {ValidateInputForm} from '../../utils/form';
import {getSiteProducts, getSitePrices, hasAvailablePrices, hasOnlyFreePlan, isInviteOnly, isFreeSignupAllowed, isPaidMembersOnly, freeHasBenefitsOrDescription, hasMultipleNewsletters, hasFreeTrialTier, isSignupAllowed, isSigninAllowed, hasCaptchaEnabled, getCaptchaSitekey} from '../../utils/helpers';
import {getSiteProducts, getSitePrices, hasAvailablePrices, hasOnlyFreePlan, isInviteOnly, isFreeSignupAllowed, isPaidMembersOnly, freeHasBenefitsOrDescription, hasMultipleNewsletters, hasFreeTrialTier, isSignupAllowed, isSigninAllowed} from '../../utils/helpers';
import {ReactComponent as InvitationIcon} from '../../images/icons/invitation.svg';
import {interceptAnchorClicks} from '../../utils/links';
import HCaptcha from '@hcaptcha/react-hcaptcha';
export const SignupPageStyles = `
.gh-portal-back-sitetitle {
@ -357,7 +356,6 @@ class SignupPage extends React.Component {
};
this.termsRef = React.createRef();
this.captchaRef = React.createRef();
}
componentDidMount() {
@ -402,16 +400,6 @@ class SignupPage extends React.Component {
};
}
doSignupWithChecks() {
const {site} = this.context;
if (hasCaptchaEnabled({site})) {
// hCaptcha's callback will call doSignup
return this.captchaRef.current.execute();
} else {
this.doSignup();
}
}
doSignup() {
this.setState((state) => {
return {
@ -450,13 +438,13 @@ class SignupPage extends React.Component {
handleSignup(e) {
e.preventDefault();
this.doSignupWithChecks();
this.doSignup();
}
handleChooseSignup(e, plan) {
e.preventDefault();
this.setState({plan}, () => {
this.doSignupWithChecks();
this.doSignup();
});
}
@ -744,16 +732,6 @@ class SignupPage extends React.Component {
onChange={(e, field) => this.handleInputChange(e, field)}
onKeyDown={e => this.onKeyDown(e)}
/>
{(hasCaptchaEnabled({site}) &&
<HCaptcha
size="invisible"
sitekey={getCaptchaSitekey({site})}
onLoad={() => this.setState({captchaLoaded: true})}
onVerify={token => this.setState({token: token}, this.doSignup)}
ref={this.captchaRef}
id="hcaptcha-signup"
/>
)}
</div>
<div>
{(hasOnlyFree ?

View file

@ -1,10 +1,7 @@
import {vi} from 'vitest';
import {getFreeProduct, getProductData, getSiteData} from '../../utils/fixtures-generator';
import {render, fireEvent, getByTestId, queryByTestId} from '../../utils/test-utils';
import SignupPage from './SignupPage';
vi.mock('@hcaptcha/react-hcaptcha');
const setup = (overrides) => {
const {mockOnActionFn, ...utils} = render(
<SignupPage />,
@ -215,42 +212,4 @@ describe('SignupPage', () => {
expect(signinLink).toBeInTheDocument();
});
});
describe('when captcha is enabled', () => {
test('renders', () => {
setup({
site: getSiteData({
captchaEnabled: true,
captchaSiteKey: '20000000-ffff-ffff-ffff-000000000002',
products: [
getFreeProduct({})
]
})
});
const hcaptchaElement = getByTestId(document.body, 'hcaptcha-mock');
expect(hcaptchaElement).toBeInTheDocument();
});
test('uses Captcha when run', () => {
const {nameInput, emailInput, chooseButton, mockOnActionFn} = setup({
site: getSiteData({
captchaEnabled: true,
captchaSiteKey: '20000000-ffff-ffff-ffff-000000000002'
})
});
const nameVal = 'J Smith';
const emailVal = 'jsmith@example.com';
const planVal = 'free';
fireEvent.change(nameInput, {target: {value: nameVal}});
fireEvent.change(emailInput, {target: {value: emailVal}});
expect(nameInput).toHaveValue(nameVal);
expect(emailInput).toHaveValue(emailVal);
fireEvent.click(chooseButton[0]);
expect(mockOnActionFn).toHaveBeenCalledWith('signup', {email: emailVal, name: nameVal, plan: planVal, token: 'mocked-token'});
});
});
});

View file

@ -1,8 +1,11 @@
import App from '../App.js';
import {vi} from 'vitest';
import {fireEvent, appRender, within} from '../utils/test-utils';
import {site as FixtureSite} from '../utils/test-fixtures';
import setupGhostApi from '../utils/api.js';
vi.mock('@hcaptcha/react-hcaptcha');
const setup = async ({site, member = null}) => {
const ghostApi = setupGhostApi({siteUrl: 'https://example.com'});
ghostApi.init = jest.fn(() => {
@ -312,4 +315,63 @@ describe('Signin', () => {
});
});
});
describe('with captcha enabled', () => {
beforeEach(() => {
// Mock window.location
Object.defineProperty(window, 'location', {
value: new URL('https://portal.localhost/#/portal/signin'),
writable: true
});
});
afterEach(() => {
window.location = realLocation;
});
test('on a simple site', async () => {
const {ghostApi, emailInput, submitButton, popupIframeDocument} = await setup({
site: Object.assign({}, FixtureSite.singleTier.basic, {
captcha_enabled: true,
captcha_sitekey: '20000000-ffff-ffff-ffff-000000000002'
})
});
fireEvent.change(emailInput, {target: {value: 'jamie@example.com'}});
fireEvent.click(submitButton);
const magicLink = await within(popupIframeDocument).findByText(/Now check your email/i);
expect(magicLink).toBeInTheDocument();
expect(ghostApi.member.sendMagicLink).toHaveBeenLastCalledWith({
email: 'jamie@example.com',
emailType: 'signin',
integrityToken: 'testtoken',
token: 'mocked-token'
});
});
test('with multiple tiers', async () => {
const {ghostApi, emailInput, submitButton, popupIframeDocument} = await multiTierSetup({
site: Object.assign({}, FixtureSite.multipleTiers.basic, {
captcha_enabled: true,
captcha_sitekey: '20000000-ffff-ffff-ffff-000000000002'
})
});
fireEvent.change(emailInput, {target: {value: 'jamie@example.com'}});
fireEvent.click(submitButton);
const magicLink = await within(popupIframeDocument).findByText(/Now check your email/i);
expect(magicLink).toBeInTheDocument();
expect(ghostApi.member.sendMagicLink).toHaveBeenLastCalledWith({
email: 'jamie@example.com',
emailType: 'signin',
integrityToken: 'testtoken',
token: 'mocked-token'
});
});
});
});

View file

@ -1,8 +1,11 @@
import App from '../App.js';
import {vi} from 'vitest';
import {fireEvent, appRender, within, waitFor} from '../utils/test-utils';
import {offer as FixtureOffer, site as FixtureSite} from '../utils/test-fixtures';
import setupGhostApi from '../utils/api.js';
vi.mock('@hcaptcha/react-hcaptcha');
const offerSetup = async ({site, member = null, offer}) => {
const ghostApi = setupGhostApi({siteUrl: 'https://example.com'});
ghostApi.init = jest.fn(() => {
@ -883,4 +886,60 @@ describe('Signup', () => {
expect(chooseBtns).toHaveLength(3);
});
});
describe('with captcha enabled', () => {
test('on a simple site', async () => {
const {
ghostApi, emailInput, nameInput, popupIframeDocument, chooseBtns
} = await setup({
site: Object.assign({}, FixtureSite.singleTier.basic, {
captcha_enabled: true,
captcha_sitekey: '20000000-ffff-ffff-ffff-000000000002'
})
});
fireEvent.change(nameInput, {target: {value: 'Jamie Larsen'}});
fireEvent.change(emailInput, {target: {value: 'jamie@example.com'}});
fireEvent.click(chooseBtns[0]);
const magicLink = await within(popupIframeDocument).findByText(/now check your email/i);
expect(magicLink).toBeInTheDocument();
expect(ghostApi.member.sendMagicLink).toHaveBeenLastCalledWith({
email: 'jamie@example.com',
emailType: 'signup',
name: 'Jamie Larsen',
plan: 'free',
integrityToken: 'testtoken',
token: 'mocked-token'
});
});
test('on a site with multiple tiers', async () => {
const {
ghostApi, emailInput, nameInput,chooseBtns, popupIframeDocument
} = await multiTierSetup({
site: Object.assign({}, FixtureSite.multipleTiers.basic, {
captcha_enabled: true,
captcha_sitekey: '20000000-ffff-ffff-ffff-000000000002'
})
});
fireEvent.change(nameInput, {target: {value: 'Jamie Larsen'}});
fireEvent.change(emailInput, {target: {value: 'jamie@example.com'}});
fireEvent.click(chooseBtns[0]);
const magicLink = await within(popupIframeDocument).findByText(/now check your email/i);
expect(magicLink).toBeInTheDocument();
expect(ghostApi.member.sendMagicLink).toHaveBeenLastCalledWith({
email: 'jamie@example.com',
emailType: 'signup',
name: 'Jamie Larsen',
plan: 'free',
integrityToken: 'testtoken',
token: 'mocked-token'
});
});
});
});

View file

@ -45,7 +45,7 @@ export function getSiteData({
recommendations = [],
recommendationsEnabled,
captchaEnabled = false,
captchaSiteKey
captchaSitekey
} = {}) {
return {
title,
@ -75,7 +75,7 @@ export function getSiteData({
editor_default_email_recipients,
posts,
captcha_enabled: !!captchaEnabled,
captcha_sitekey: captchaSiteKey
captcha_sitekey: captchaSitekey
};
}