diff --git a/.env.example b/.env.example index e907ad8..52973c7 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,12 @@ NEXT_PUBLIC_DISCORD_URL="https://discord.gg/invite/your_discord" NEXT_PUBLIC_YOUTUBE_URL="https://youtube.com/your_channel" NEXT_PUBLIC_GITHUB_URL="https://github.com/nekiro/shibaac" +# captcha +# empty means disabled +NEXT_PUBLIC_CAPTCHA_SITE_KEY="" +CATPCHA_VERIFY_URL="https://www.google.com/recaptcha/api/siteverify" +CAPTCHA_SECRET_KEY="" + DATABASE_URL="mysql://root:secret@localhost:3306/shibaac" # protocol status diff --git a/package-lock.json b/package-lock.json index ac6bf8e..5915f57 100644 --- a/package-lock.json +++ b/package-lock.json @@ -45,6 +45,7 @@ "qrcode": "^1.5.4", "react": "18.3.1", "react-dom": "18.3.1", + "react-google-recaptcha": "^3.1.0", "react-hook-form": "^7.53.2", "react-icons": "^5.3.0", "sanitize-html": "^2.13.1", @@ -58,9 +59,10 @@ "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.0.1", "@types/jest": "^29.5.14", - "@types/mercadopago": "^1.5.11", "@types/node-cron": "^3.0.11", "@types/react": "^18.3.12", + "@types/react-google-recaptcha": "^2.1.9", + "@types/recaptcha2": "^1.3.4", "@types/sanitize-html": "^2.13.0", "@types/speakeasy": "^2.0.10", "@types/yup": "^0.29.14", @@ -3480,12 +3482,6 @@ "@types/lodash": "*" } }, - "node_modules/@types/mercadopago": { - "version": "1.5.11", - "resolved": "https://registry.npmjs.org/@types/mercadopago/-/mercadopago-1.5.11.tgz", - "integrity": "sha512-ligRLboSSLYRzwI1oE27wO+AkZXvCsq2YwBThfH5H+byqCkQEAnmr6Nl1HRAdiZCfIPK2eK0b1rgiuatjZWd2g==", - "dev": true - }, "node_modules/@types/mime": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.2.tgz", @@ -3566,6 +3562,21 @@ "@types/react": "*" } }, + "node_modules/@types/react-google-recaptcha": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@types/react-google-recaptcha/-/react-google-recaptcha-2.1.9.tgz", + "integrity": "sha512-nT31LrBDuoSZJN4QuwtQSF3O89FVHC4jLhM+NtKEmVF5R1e8OY0Jo4//x2Yapn2aNHguwgX5doAq8Zo+Ehd0ug==", + "dev": true, + "dependencies": { + "@types/react": "*" + } + }, + "node_modules/@types/recaptcha2": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@types/recaptcha2/-/recaptcha2-1.3.4.tgz", + "integrity": "sha512-FRIHh0mcbGvFROQuLhbFunRetHZOFSUEc54tYFnmBWAwyay1wxd/5HmseUdn+h7e03q8CDzlNXCE5q6YokWtTA==", + "dev": true + }, "node_modules/@types/sanitize-html": { "version": "2.13.0", "resolved": "https://registry.npmjs.org/@types/sanitize-html/-/sanitize-html-2.13.0.tgz", @@ -9321,9 +9332,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.6.tgz", - "integrity": "sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==", + "version": "3.3.8", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", + "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==", "funding": [ { "type": "github", @@ -10257,6 +10268,18 @@ "node": ">=0.10.0" } }, + "node_modules/react-async-script": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/react-async-script/-/react-async-script-1.2.0.tgz", + "integrity": "sha512-bCpkbm9JiAuMGhkqoAiC0lLkb40DJ0HOEJIku+9JDjxX3Rcs+ztEOG13wbrOskt3n2DTrjshhaQ/iay+SnGg5Q==", + "dependencies": { + "hoist-non-react-statics": "^3.3.0", + "prop-types": "^15.5.0" + }, + "peerDependencies": { + "react": ">=16.4.1" + } + }, "node_modules/react-clientside-effect": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/react-clientside-effect/-/react-clientside-effect-1.2.6.tgz", @@ -10307,6 +10330,18 @@ } } }, + "node_modules/react-google-recaptcha": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/react-google-recaptcha/-/react-google-recaptcha-3.1.0.tgz", + "integrity": "sha512-cYW2/DWas8nEKZGD7SCu9BSuVz8iOcOLHChHyi7upUuVhkpkhYG/6N3KDiTQ3XAiZ2UAZkfvYKMfAHOzBOcGEg==", + "dependencies": { + "prop-types": "^15.5.0", + "react-async-script": "^1.2.0" + }, + "peerDependencies": { + "react": ">=16.4.1" + } + }, "node_modules/react-hook-form": { "version": "7.53.2", "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.53.2.tgz", diff --git a/package.json b/package.json index 5c8292f..4187fd0 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "qrcode": "^1.5.4", "react": "18.3.1", "react-dom": "18.3.1", + "react-google-recaptcha": "^3.1.0", "react-hook-form": "^7.53.2", "react-icons": "^5.3.0", "sanitize-html": "^2.13.1", @@ -68,9 +69,10 @@ "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.0.1", "@types/jest": "^29.5.14", - "@types/mercadopago": "^1.5.11", "@types/node-cron": "^3.0.11", "@types/react": "^18.3.12", + "@types/react-google-recaptcha": "^2.1.9", + "@types/recaptcha2": "^1.3.4", "@types/sanitize-html": "^2.13.0", "@types/speakeasy": "^2.0.10", "@types/yup": "^0.29.14", diff --git a/src/components/Button.tsx b/src/components/Button.tsx index d34b6c4..2c6c79e 100644 --- a/src/components/Button.tsx +++ b/src/components/Button.tsx @@ -1,6 +1,7 @@ import React from "react"; import Link from "next/link"; import { Button as ChakraButton, ButtonProps as ChakraButtonProps } from "@chakra-ui/react"; +import { useColors } from "@hook/useColors"; const btnTypeToColor = { danger: "red", primary: "violet" }; @@ -22,7 +23,7 @@ const Button = ({ value, type = "button", btnColorType, - size = "md", + size = "lg", href, isLoading = false, isActive = false, @@ -39,6 +40,7 @@ const Button = ({ isLoading={isLoading} isActive={isActive} loadingText={loadingText} + color="text.light" {...props} > {value ?? children} diff --git a/src/components/Captcha.tsx b/src/components/Captcha.tsx new file mode 100644 index 0000000..751c465 --- /dev/null +++ b/src/components/Captcha.tsx @@ -0,0 +1,15 @@ +import { forwardRef } from "react"; +import ReCAPTCHA from "react-google-recaptcha"; + +export interface CaptchaProps { + onChange: (token: string | null) => void; +} + +export const Captcha = forwardRef(({ onChange }, ref) => { + if (!process.env.NEXT_PUBLIC_CAPTCHA_SITE_KEY) { + return null; + } + return ; +}); + +Captcha.displayName = "Captcha"; diff --git a/src/components/FormField.tsx b/src/components/FormField.tsx index 83e6862..46c0ca3 100644 --- a/src/components/FormField.tsx +++ b/src/components/FormField.tsx @@ -1,18 +1,19 @@ -import { FormControl, FormLabel, FormErrorMessage } from "@chakra-ui/react"; -import { PropsWithChildren } from "react"; +import { FormControl, FormLabel, FormErrorMessage, FormControlProps } from "@chakra-ui/react"; -export interface FormFieldProps extends PropsWithChildren { +export interface FormFieldProps extends FormControlProps { name: string; - label: string; + label?: string; error?: string; } -export const FormField = ({ name, label, error, children }: FormFieldProps) => { +export const FormField = ({ name, label, error, children, ...props }: FormFieldProps) => { return ( - - - {label} - + + {label && ( + + {label} + + )} {children} {error && {error}} diff --git a/src/components/TextInput.tsx b/src/components/TextInput.tsx index 893f4ef..144c453 100644 --- a/src/components/TextInput.tsx +++ b/src/components/TextInput.tsx @@ -17,6 +17,7 @@ const TextInput = forwardRef(({ ...props }, ref) = borderColor="violet.200" bg={inputBgColor} color="black" + height="45px" {...props} /> ); diff --git a/src/lib/captcha.ts b/src/lib/captcha.ts new file mode 100644 index 0000000..f632eda --- /dev/null +++ b/src/lib/captcha.ts @@ -0,0 +1,13 @@ +export const verifyCaptcha = async (token: string) => { + if (!process.env.CAPTCHA_SECRET_KEY || !process.env.CATPCHA_VERIFY_URL) { + return; + } + + const verificationUrl = `${process.env.CATPCHA_VERIFY_URL}?secret=${process.env.CAPTCHA_SECRET_KEY}&response=${token}`; + const captchaResponse = await fetch(verificationUrl, { method: "POST" }); + const captchaResult = await captchaResponse.json(); + + if (!captchaResult.success) { + throw new Error("Captcha verification failed."); + } +}; diff --git a/src/pages/account/index.tsx b/src/pages/account/index.tsx index 522fa4c..2f5a5b6 100644 --- a/src/pages/account/index.tsx +++ b/src/pages/account/index.tsx @@ -1,30 +1,10 @@ -import React, { useEffect, useState } from "react"; import Panel from "@component/Panel"; import Head from "@layout/Head"; -import { fetchApi } from "@lib/request"; import { withSessionSsr } from "@lib/session"; import Button from "@component/Button"; import StripedTable from "@component/StrippedTable"; -import { - Alert, - AlertIcon, - Box, - Button as ChakraButton, - Center, - Image, - Modal, - ModalBody, - ModalCloseButton, - ModalContent, - ModalFooter, - ModalHeader, - ModalOverlay, - Spinner, - Text, - Wrap, -} from "@chakra-ui/react"; +import { Text, Wrap } from "@chakra-ui/react"; import { timestampToDate, vocationIdToName } from "../../lib"; -import { Toggle } from "../../components/Toggle"; import { appRouter } from "src/server/routers/_app"; import { createCallerFactory } from "src/server/trpc"; import type { AccountWithPlayers } from "@shared/types/PrismaAccount"; diff --git a/src/pages/account/login.tsx b/src/pages/account/login.tsx index 85dd15e..0e0d637 100644 --- a/src/pages/account/login.tsx +++ b/src/pages/account/login.tsx @@ -1,10 +1,10 @@ -import React from "react"; +import React, { useRef } from "react"; import Panel from "@component/Panel"; import Head from "@layout/Head"; import Link from "@component/Link"; import { useRouter } from "next/router"; import { withSessionSsr } from "@lib/session"; -import { Text, Container, VStack, Wrap } from "@chakra-ui/react"; +import { Text, Container, VStack, Wrap, HStack } from "@chakra-ui/react"; import { trpc } from "@util/trpc"; import { useFormFeedback } from "@hook/useFormFeedback"; import TextInput from "@component/TextInput"; @@ -13,6 +13,8 @@ import { FormField } from "@component/FormField"; import { useForm, SubmitHandler } from "react-hook-form"; import { z } from "zod"; import { zodResolver } from "@hookform/resolvers/zod"; +import { Captcha } from "@component/Captcha"; +import ReCAPTCHA from "react-google-recaptcha"; const fields = [ { type: "input", name: "name", label: "Account Name" }, @@ -30,6 +32,7 @@ const fields = [ const schema = z.object({ name: z.string().min(5, { message: "Account name must be at least 5 characters long" }), password: z.string().min(6, { message: "Password must be at least 6 characters long" }), + captcha: z.string({ message: "Captcha is required" }), }); export default function Login() { @@ -37,17 +40,20 @@ export default function Login() { register, handleSubmit, reset, + setValue, + trigger, formState: { errors, isValid, isSubmitting }, } = useForm>({ resolver: zodResolver(schema), }); + const captchaRef = useRef(null); const router = useRouter(); const login = trpc.account.login.useMutation(); const { handleResponse, showResponse } = useFormFeedback(); - const onSubmit: SubmitHandler> = async ({ name, password }) => { + const onSubmit: SubmitHandler> = async ({ name, password, captcha }) => { handleResponse(async () => { - const account = await login.mutateAsync({ name, password }); + const account = await login.mutateAsync({ name, password, captchaToken: captcha }); if (account) { const redirectUrl = (router.query.redirect as string) || "/account"; router.push(redirectUrl); @@ -57,35 +63,56 @@ export default function Login() { }); reset(); + + if (captchaRef.current) { + captchaRef.current.reset(); + } }; return ( <> - - - - Please enter your account name and your password. - - - - if you do not have one yet. - + + + +
+ + + {fields.map((field) => ( + + + + ))} - - - - {fields.map((field) => ( - - + + { + setValue("captcha", token ?? ""); + trigger("captcha"); + }} + ref={captchaRef} + /> - ))} - -