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:
parent
13c5c5ff17
commit
2740686d53
11 changed files with 177 additions and 127 deletions
|
@ -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'
|
||||
});
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
|
|
|
@ -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)
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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') {
|
||||
|
|
|
@ -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()}
|
||||
|
|
|
@ -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()}
|
||||
|
|
|
@ -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'});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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 ?
|
||||
|
|
|
@ -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'});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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'
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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'
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
|
Loading…
Add table
Reference in a new issue