From 2866cd32c1bb2c91e2a4e96647d474fdaa2ea1dd Mon Sep 17 00:00:00 2001 From: aelassas Date: Sat, 14 Dec 2024 15:52:34 +0100 Subject: [PATCH] Fix reCAPTCHA in Safari and other issues --- api/src/controllers/userController.ts | 21 +++--- api/src/routes/bookingRoutes.ts | 2 +- frontend/index.html | 27 ++++--- frontend/package-lock.json | 14 ---- frontend/package.json | 1 - frontend/src/App.tsx | 65 ++++++++-------- frontend/src/assets/css/home.css | 5 +- frontend/src/assets/css/search.css | 5 ++ frontend/src/common/helper.ts | 20 +++++ frontend/src/components/CheckoutStatus.tsx | 4 +- frontend/src/components/ContactForm.tsx | 42 +++++------ frontend/src/components/DriverLicense.tsx | 6 +- frontend/src/components/NewsletterForm.tsx | 34 ++++----- frontend/src/components/ReCaptchaProvider.tsx | 23 ------ frontend/src/hooks/useRecaptcha.ts | 75 +++++++++++++++++++ frontend/src/pages/Activate.tsx | 8 +- frontend/src/pages/ChangePassword.tsx | 5 +- frontend/src/pages/Checkout.tsx | 37 ++++----- frontend/src/pages/ResetPassword.tsx | 6 +- frontend/src/pages/Search.tsx | 1 + frontend/src/pages/SignUp.tsx | 37 ++++----- packages/bookcars-types/index.ts | 2 - 22 files changed, 245 insertions(+), 195 deletions(-) delete mode 100644 frontend/src/components/ReCaptchaProvider.tsx create mode 100644 frontend/src/hooks/useRecaptcha.ts diff --git a/api/src/controllers/userController.ts b/api/src/controllers/userController.ts index 73dc9c108..a68ac6c6b 100644 --- a/api/src/controllers/userController.ts +++ b/api/src/controllers/userController.ts @@ -1539,15 +1539,18 @@ export const verifyRecaptcha = async (req: Request, res: Response) => { */ export const sendEmail = async (req: Request, res: Response) => { try { - const { body }: { body: bookcarsTypes.SendEmailPayload } = req - const { from, to, subject, message, recaptchaToken: token, ip, isContactForm } = body - const result = await axios.get(`https://www.google.com/recaptcha/api/siteverify?secret=${encodeURIComponent(env.RECAPTCHA_SECRET)}&response=${encodeURIComponent(token)}&remoteip=${ip}`) - const { success } = result.data - - if (!success) { - return res.sendStatus(400) + const whitelist = [ + helper.trimEnd(env.BACKEND_HOST, '/'), + helper.trimEnd(env.FRONTEND_HOST, '/'), + ] + const { origin } = req.headers + if (!origin || whitelist.indexOf(helper.trimEnd(origin, '/')) === -1) { + throw new Error('Unauthorized!') } + const { body }: { body: bookcarsTypes.SendEmailPayload } = req + const { from, to, subject, message, isContactForm } = body + const mailOptions: nodemailer.SendMailOptions = { from: env.SMTP_FROM, to, @@ -1563,8 +1566,8 @@ export const sendEmail = async (req: Request, res: Response) => { return res.sendStatus(200) } catch (err) { - logger.error(`[user.delete] ${i18n.t('DB_ERROR')} ${JSON.stringify(req.body)}`, err) - return res.status(400).send(i18n.t('DB_ERROR') + err) + logger.error(`[user.sendEmail] ${JSON.stringify(req.body)}`, err) + return res.status(400).send(err) } } diff --git a/api/src/routes/bookingRoutes.ts b/api/src/routes/bookingRoutes.ts index 1f847f4c1..7a472a599 100644 --- a/api/src/routes/bookingRoutes.ts +++ b/api/src/routes/bookingRoutes.ts @@ -11,7 +11,7 @@ routes.route(routeNames.update).put(authJwt.verifyToken, bookingController.updat routes.route(routeNames.updateStatus).post(authJwt.verifyToken, bookingController.updateStatus) routes.route(routeNames.delete).post(authJwt.verifyToken, bookingController.deleteBookings) routes.route(routeNames.deleteTempBooking).delete(bookingController.deleteTempBooking) -routes.route(routeNames.getBooking).get(authJwt.verifyToken, bookingController.getBooking) +routes.route(routeNames.getBooking).get(bookingController.getBooking) routes.route(routeNames.getBookingId).get(bookingController.getBookingId) routes.route(routeNames.getBookings).post(authJwt.verifyToken, bookingController.getBookings) routes.route(routeNames.hasBookings).get(authJwt.verifyToken, bookingController.hasBookings) diff --git a/frontend/index.html b/frontend/index.html index c584ad38e..18b57a264 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,15 +1,18 @@ - - - - - - - BookCars Rental Service - - -
- - + + + + + + + + BookCars Rental Service + + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 40ddb838e..25a3c15b1 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -41,7 +41,6 @@ "react-circle-flags": "^0.0.23", "react-dom": "^19.0.0", "react-ga4": "^2.1.0", - "react-google-recaptcha-v3": "^1.10.1", "react-leaflet": "^5.0.0-rc.2", "react-localization": "^1.0.19", "react-router-dom": "^7.0.2", @@ -5939,19 +5938,6 @@ "integrity": "sha512-ZKS7PGNFqqMd3PJ6+C2Jtz/o1iU9ggiy8Y8nUeksgVuvNISbmrQtJiZNvC/TjDsqD0QlU5Wkgs7i+w9+OjHhhQ==", "license": "MIT" }, - "node_modules/react-google-recaptcha-v3": { - "version": "1.10.1", - "resolved": "https://registry.npmjs.org/react-google-recaptcha-v3/-/react-google-recaptcha-v3-1.10.1.tgz", - "integrity": "sha512-K3AYzSE0SasTn+XvV2tq+6YaxM+zQypk9rbCgG4OVUt7Rh4ze9basIKefoBz9sC0CNslJj9N1uwTTgRMJQbQJQ==", - "license": "MIT", - "dependencies": { - "hoist-non-react-statics": "^3.3.2" - }, - "peerDependencies": { - "react": "^16.3 || ^17.0 || ^18.0", - "react-dom": "^17.0 || ^18.0" - } - }, "node_modules/react-is": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index d5c9a0430..30aa1ee13 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -48,7 +48,6 @@ "react-circle-flags": "^0.0.23", "react-dom": "^19.0.0", "react-ga4": "^2.1.0", - "react-google-recaptcha-v3": "^1.10.1", "react-leaflet": "^5.0.0-rc.2", "react-localization": "^1.0.19", "react-router-dom": "^7.0.2", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3b7865f31..5e816561b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,7 +4,6 @@ import SuspenseRouter from '@/components/SuspenseRouter' import env from '@/config/env.config' import { GlobalProvider } from '@/context/GlobalContext' import { init as initGA } from '@/common/ga4' -import ReCaptchaProvider from '@/components/ReCaptchaProvider' import ScrollToTop from '@/components/ScrollToTop' if (env.GOOGLE_ANALYTICS_ENABLED) { @@ -36,41 +35,39 @@ const Faq = lazy(() => import('@/pages/Faq')) const App = () => ( - - - + + -
- }> - - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> +
+ }> + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> - } /> - - -
- - + } /> +
+
+
+
) diff --git a/frontend/src/assets/css/home.css b/frontend/src/assets/css/home.css index 0cc80b702..c34c09481 100644 --- a/frontend/src/assets/css/home.css +++ b/frontend/src/assets/css/home.css @@ -408,9 +408,8 @@ div.home div.customer-care div.customer-care-img img { } div.home div.search div.home-search { - width: calc(100% - 40px) !important; - padding: 20px; - margin-top: -187px; + padding: 20px 10px; + margin: -187px 20px 0 20px; } div.home div.why div.why-boxes, diff --git a/frontend/src/assets/css/search.css b/frontend/src/assets/css/search.css index 9d99e319e..58ed87e4d 100644 --- a/frontend/src/assets/css/search.css +++ b/frontend/src/assets/css/search.css @@ -62,6 +62,11 @@ div.search div.col-1 .filter { justify-content: center; } +div.search .btn-filters { + max-width: 480px; + margin-top: 5px; +} + /* Device width is less than or equal to 960px */ @media only screen and (width <=960px) { diff --git a/frontend/src/common/helper.ts b/frontend/src/common/helper.ts index 2bfec7c79..c4fb18ed7 100644 --- a/frontend/src/common/helper.ts +++ b/frontend/src/common/helper.ts @@ -5,6 +5,7 @@ import { strings } from '@/lang/cars' import { strings as commonStrings } from '@/lang/common' import env from '@/config/env.config' import * as StripeService from '@/services/StripeService' +import * as UserService from '@/services/UserService' /** * Get language. @@ -638,3 +639,22 @@ export const downloadURI = (uri: string, name: string = '') => { link.click() link.remove() } + +/** + * Verify reCAPTCHA token. + * + * @async + * @param {string} token + * @returns {Promise} + */ +export const verifyReCaptcha = async (token: string): Promise => { + try { + const ip = await UserService.getIP() + const status = await UserService.verifyRecaptcha(token, ip) + const valid = status === 200 + return valid + } catch (err) { + error(err) + return false + } +} diff --git a/frontend/src/components/CheckoutStatus.tsx b/frontend/src/components/CheckoutStatus.tsx index d94953756..24f2d3498 100644 --- a/frontend/src/components/CheckoutStatus.tsx +++ b/frontend/src/components/CheckoutStatus.tsx @@ -63,7 +63,9 @@ const CheckoutStatus = ( diff --git a/frontend/src/components/ContactForm.tsx b/frontend/src/components/ContactForm.tsx index cf1b8d03e..ae25953e9 100644 --- a/frontend/src/components/ContactForm.tsx +++ b/frontend/src/components/ContactForm.tsx @@ -1,5 +1,4 @@ -import React, { useCallback, useEffect, useState } from 'react' -import { GoogleReCaptcha } from 'react-google-recaptcha-v3' +import React, { useEffect, useState } from 'react' import { OutlinedInput, InputLabel, @@ -16,6 +15,8 @@ import env from '@/config/env.config' import { strings as commonStrings } from '@/lang/common' import { strings } from '@/lang/contact-form' import * as UserService from '@/services/UserService' +import useReCaptcha from '@/hooks/useRecaptcha' + import * as helper from '@/common/helper' import '@/assets/css/contact-form.css' @@ -27,14 +28,13 @@ interface ContactFormProps { const ContactForm = ({ user, className }: ContactFormProps) => { const navigate = useNavigate() + const { reCaptchaLoaded, generateReCaptchaToken } = useReCaptcha() const [email, setEmail] = useState('') const [isAuthenticated, setIsAuthenticated] = useState(false) const [emailValid, setEmailValid] = useState(true) const [subject, setSubject] = useState('') const [message, setMessage] = useState('') - const [recaptchaToken, setRecaptchaToken] = useState('') - const [refreshReCaptcha, setRefreshReCaptcha] = useState(false) const [sending, setSending] = useState(false) useEffect(() => { @@ -74,29 +74,33 @@ const ContactForm = ({ user, className }: ContactFormProps) => { setMessage(e.target.value) } - const handleRecaptchaVerify = useCallback(async (token: string) => { - setRecaptchaToken(token) - }, []) - const handleSubmit = async (e: React.FormEvent) => { try { - setSending(true) e.preventDefault() + setSending(true) const _emailValid = await validateEmail(email) if (!_emailValid) { return } - const ip = await UserService.getIP() + let recaptchaToken = '' + if (reCaptchaLoaded) { + recaptchaToken = await generateReCaptchaToken() + if (!(await helper.verifyReCaptcha(recaptchaToken))) { + recaptchaToken = '' + } + + if (!recaptchaToken) { + helper.error('reCAPTCHA error') + } + } const payload: bookcarsTypes.SendEmailPayload = { from: email, to: env.CONTACT_EMAIL, subject, message, - recaptchaToken, - ip, isContactForm: true, } const status = await UserService.sendEmail(payload) @@ -115,7 +119,6 @@ const ContactForm = ({ user, className }: ContactFormProps) => { helper.error(err) } finally { setSending(false) - setRefreshReCaptcha((refresh) => !refresh) } } @@ -170,15 +173,8 @@ const ContactForm = ({ user, className }: ContactFormProps) => { /> -
- -
-
-
- - ) } diff --git a/frontend/src/components/ReCaptchaProvider.tsx b/frontend/src/components/ReCaptchaProvider.tsx deleted file mode 100644 index 9fb09c60e..000000000 --- a/frontend/src/components/ReCaptchaProvider.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import React, { ReactNode } from 'react' -import { GoogleReCaptchaProvider } from 'react-google-recaptcha-v3' -import env from '@/config/env.config' -import * as UserService from '@/services/UserService' - -interface ReCaptchaProviderProps { - children: ReactNode -} - -const ReCaptchaProvider = ({ children }: ReCaptchaProviderProps) => ( - env.RECAPTCHA_ENABLED - ? ( - - {children} - - ) - : <>{children} -) - -export default ReCaptchaProvider diff --git a/frontend/src/hooks/useRecaptcha.ts b/frontend/src/hooks/useRecaptcha.ts new file mode 100644 index 000000000..c763b7d06 --- /dev/null +++ b/frontend/src/hooks/useRecaptcha.ts @@ -0,0 +1,75 @@ +import { useEffect, useState } from 'react' +import env from '@/config/env.config' + +declare global { + interface Window { + grecaptcha: any; + } +} + +const { RECAPTCHA_SITE_KEY } = env + +const showBadge = () => { + if (!window.grecaptcha) return + window.grecaptcha.ready(() => { + const badge = document.getElementsByClassName('grecaptcha-badge')[0] as HTMLElement + if (!badge) return + badge.style.display = 'block' + badge.style.zIndex = '1' + }) +} + +const hideBadge = () => { + if (!window.grecaptcha) return + window.grecaptcha.ready(() => { + const badge = document.getElementsByClassName('grecaptcha-badge')[0] as HTMLElement + if (!badge) return + badge.style.display = 'none' + }) +} + +const useReCaptcha = (): { reCaptchaLoaded: boolean; generateReCaptchaToken: (action?: string) => Promise } => { + const [reCaptchaLoaded, setReCaptchaLoaded] = useState(false) + + // Load ReCaptcha script + useEffect(() => { + if (!env.RECAPTCHA_ENABLED) return + if (env.isSafari) return + if (typeof window === 'undefined' || reCaptchaLoaded) return + if (window.grecaptcha) { + showBadge() + setReCaptchaLoaded(true) + return + } + const script = document.createElement('script') + script.async = true + script.src = `https://www.google.com/recaptcha/api.js?render=${RECAPTCHA_SITE_KEY}` + script.addEventListener('load', () => { + setReCaptchaLoaded(true) + showBadge() + }) + document.body.appendChild(script) + }, [reCaptchaLoaded]) + + // Hide badge when unmount + useEffect(() => hideBadge, []) + + // Get token + const generateReCaptchaToken = (action: string = 'submit'): Promise => new Promise((resolve, reject) => { + if (env.isSafari) resolve('') + if (!reCaptchaLoaded) reject(new Error('ReCaptcha not loaded')) + if (typeof window === 'undefined' || !window.grecaptcha) { + setReCaptchaLoaded(false) + reject(new Error('ReCaptcha not loaded')) + } + window.grecaptcha.ready(() => { + window.grecaptcha.execute(RECAPTCHA_SITE_KEY, { action }).then((token: string) => { + resolve(token) + }) + }) + }) + + return { reCaptchaLoaded, generateReCaptchaToken } +} + +export default useReCaptcha diff --git a/frontend/src/pages/Activate.tsx b/frontend/src/pages/Activate.tsx index 152d2f4d8..d507fb5f0 100644 --- a/frontend/src/pages/Activate.tsx +++ b/frontend/src/pages/Activate.tsx @@ -39,10 +39,12 @@ const Activate = () => { const handleNewPasswordChange = (e: React.ChangeEvent) => { setPassword(e.target.value) + setConfirmPasswordError(false) } const handleConfirmPasswordChange = (e: React.ChangeEvent) => { setConfirmPassword(e.target.value) + setConfirmPasswordError(false) } const handleSubmit = async (e: React.FormEvent | React.KeyboardEvent) => { @@ -160,7 +162,7 @@ const Activate = () => {

{strings.ACTIVATE_HEADING}

{strings.TOKEN_EXPIRED} -

@@ -204,10 +206,10 @@ const Activate = () => {

- -
diff --git a/frontend/src/pages/ChangePassword.tsx b/frontend/src/pages/ChangePassword.tsx index ffe91d412..499e48672 100644 --- a/frontend/src/pages/ChangePassword.tsx +++ b/frontend/src/pages/ChangePassword.tsx @@ -36,10 +36,12 @@ const ChangePassword = () => { const handleNewPasswordChange = (e: React.ChangeEvent) => { setNewPassword(e.target.value) + setConfirmPasswordError(false) } const handleConfirmPasswordChange = (e: React.ChangeEvent) => { setConfirmPassword(e.target.value) + setConfirmPasswordError(false) } const handleCurrentPasswordChange = (e: React.ChangeEvent) => { @@ -187,9 +189,8 @@ const ChangePassword = () => {