diff --git a/packages/app/package.json b/packages/app/package.json index fbde7af4..e887f73c 100755 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -68,6 +68,7 @@ "react-player": "^2.16.0", "react-rnd": "^10.4.14", "react-transition-group": "^4.4.5", + "react-turnstile": "^1.1.4", "react-useanimations": "^2.10.0", "remark-gfm": "^3.0.1", "rxjs": "^7.5.5", diff --git a/packages/app/src/pages/auth/forms/register/index.jsx b/packages/app/src/pages/auth/forms/register/index.jsx index f4a42f14..d1f6afeb 100755 --- a/packages/app/src/pages/auth/forms/register/index.jsx +++ b/packages/app/src/pages/auth/forms/register/index.jsx @@ -12,8 +12,9 @@ import UsernameStep from "./steps/username" import PasswordStep from "./steps/password" import EmailStep from "./steps/email" import TOSStep from "./steps/tos" +import CaptchaStep from "./steps/captcha" -const steps = [UsernameStep, PasswordStep, EmailStep, TOSStep] +const steps = [UsernameStep, PasswordStep, EmailStep, TOSStep, CaptchaStep] const RegisterForm = (props) => { const [finishing, setFinishing] = React.useState(false) @@ -35,6 +36,7 @@ const RegisterForm = (props) => { password: stepsValues.password, email: stepsValues.email, tos: stepsValues.tos, + captcha: stepsValues.captcha, }).catch((err) => { setFinishSuccess(false) setFinishing(false) @@ -107,8 +109,8 @@ const RegisterForm = (props) => { <>

👋 Hi! Nice to meet you

- Tell us some basic information to get started - creating your account. + Tell us some basic information to get started creating your + account.

)} @@ -116,8 +118,7 @@ const RegisterForm = (props) => { {!finishSuccess && !finishing && step > 0 && ( <>

- {currentStepData?.icon && - createIconRender(currentStepData.icon)} + {currentStepData?.icon && createIconRender(currentStepData.icon)} {currentStepData?.title}

@@ -150,10 +151,7 @@ const RegisterForm = (props) => {

Welcome abord!

-

- One last step, we need you to login with your new - account. -

+

One last step, we need you to login with your new account.

{ )} {finishError && ( - + )} {!finishSuccess && !finishing && (
{step === 0 && ( - props.setActiveKey("selector")} - > + props.setActiveKey("selector")}> Cancel )} {step > 0 && ( - prevStep()}> - Back - + prevStep()}>Back )} { + return ( + { + props.updateValue(token) + }} + /> + ) +} + +export default { + key: "captcha", + title: "Step 4", + icon: "FiLock", + description: + "We need you to prove that you are a human. Please enter the captcha below.", + required: true, + content: CaptchaStepComponent, +} diff --git a/packages/app/src/pages/auth/forms/register/steps/email/index.jsx b/packages/app/src/pages/auth/forms/register/steps/email/index.jsx index 1b8940a5..04a7474e 100755 --- a/packages/app/src/pages/auth/forms/register/steps/email/index.jsx +++ b/packages/app/src/pages/auth/forms/register/steps/email/index.jsx @@ -4,87 +4,103 @@ import * as antd from "antd" import AuthModel from "@models/auth" const EmailStepComponent = (props) => { - const [email, setEmail] = React.useState(props.currentValue ?? "") + const [email, setEmail] = React.useState(props.currentValue ?? "") - const [loading, setLoading] = React.useState(false) - const [validFormat, setValidFormat] = React.useState(null) - const [emailAvailable, setEmailAvailable] = React.useState(null) + const [loading, setLoading] = React.useState(false) + const [validFormat, setValidFormat] = React.useState(null) + const [emailAvailable, setEmailAvailable] = React.useState(null) - const isValid = () => { - return email.length > 0 && validFormat && emailAvailable - } + const isValid = () => { + return email.length > 0 && validFormat && emailAvailable + } - const checkIfIsEmail = (email) => { - return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) - } + const checkIfIsEmail = (email) => { + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) + } - const submit = () => { - if (!isValid()) return + const submit = () => { + if (!isValid()) { + return false + } - props.onPressEnter() - } + props.onPressEnter() + } - const handleUpdate = (e) => { - setEmail(e.target.value) - } + const handleUpdate = (e) => { + setEmail(e.target.value) + } - React.useEffect(() => { - if (email.length === 0) { - setEmailAvailable(null) - setValidFormat(null) + React.useEffect(() => { + if (email.length === 0) { + setEmailAvailable(null) + setValidFormat(null) - return - } + return + } - props.updateValue(null) + props.updateValue(null) - setLoading(true) + setLoading(true) - setValidFormat(checkIfIsEmail(email)) + const isEmailValid = checkIfIsEmail(email) + setValidFormat(isEmailValid) - // check if email is available - const timer = setTimeout(async () => { - if (!validFormat) return + // check if email is available + const timer = setTimeout(async () => { + if (!isEmailValid) { + return false + } - const request = await AuthModel.availability({ email }).catch((error) => { - antd.message.error(`Cannot check email availability: ${error.message}`) + const request = await AuthModel.availability({ email }).catch((error) => { + antd.message.error(`Cannot check email availability: ${error.message}`) - return false - }) + return false + }) - if (request) { - setEmailAvailable(!request.exist) + if (request) { + setEmailAvailable(!request.exist) - if (request.exist) { - antd.message.error("Email is already in use") - props.updateValue(null) - } else { - props.updateValue(email) - } - } + if (request.exist) { + antd.message.error("Email is already in use") + props.updateValue(null) + } else { + props.updateValue(email) + } + } - setLoading(false) - }, 1000) + setLoading(false) + }, 1000) - return () => clearTimeout(timer) - }, [email]) + return () => clearTimeout(timer) + }, [email]) - return
- -
+ return ( +
+ +
+ ) } export default { - key: "email", - title: "Step 3", - icon: "FiMail", - description: "Enter a email for the account, it can be used to access to your account. \n Will not be shared with anyone else and not be used for marketing purposes.", - required: true, - content: EmailStepComponent, -} \ No newline at end of file + key: "email", + title: "Step 3", + icon: "FiMail", + description: + "Enter a email for the account, it can be used to access to your account. \n Will not be shared with anyone else and not be used for marketing purposes.", + required: true, + content: EmailStepComponent, +} diff --git a/packages/app/src/pages/auth/forms/register/steps/tos/index.jsx b/packages/app/src/pages/auth/forms/register/steps/tos/index.jsx index b361e493..0c14a226 100755 --- a/packages/app/src/pages/auth/forms/register/steps/tos/index.jsx +++ b/packages/app/src/pages/auth/forms/register/steps/tos/index.jsx @@ -1,80 +1,81 @@ -import React from "react" import * as antd from "antd" import MarkdownReader from "@components/MarkdownReader" import config from "@config" const FrameStyle = { - "width": "60vw", - "max-width": "60vw", - "height": "90vh", - "max-height": "90vh", - "overflow": "overlay", - "justify-content": "flex-start", + width: "60vw", + "max-width": "60vw", + height: "90vh", + "max-height": "90vh", + overflow: "overlay", + "justify-content": "flex-start", } const LegalDocumentsDecorators = { - "terms": "Terms of Service", - "privacy": "Privacy Policy", + terms: "Terms of Service", + privacy: "Privacy Policy", } function composeConfirmationCheckboxLabel(documents) { - let labels = [ - "I have read and accept" - ] + let labels = ["I have read and accept"] - documents.forEach(([key, value], index) => { - const isLast = index === documents.length - 1 + documents.forEach(([key, value], index) => { + const isLast = index === documents.length - 1 - labels.push(`the ${LegalDocumentsDecorators[key] ?? `document (${key})`} ${!isLast ? "and" : ""}`) - }) + labels.push( + `the ${LegalDocumentsDecorators[key] ?? `document (${key})`} ${!isLast ? "and" : ""}`, + ) + }) - return labels.join(" ") + return labels.join(" ") } const TermsOfServiceStepComponent = (props) => { - const legalDocuments = Object.entries(config.legal) + const legalDocuments = Object.entries(config.legal) - return
- { - Object.entries(config.legal).map(([key, value]) => { - if (!value) { - return null - } + return ( +
+ {Object.entries(config.legal).map(([key, value]) => { + if (!value) { + return null + } - return { - app.layout.modal.open(key, MarkdownReader, { - includeCloseButton: true, - frameContentStyle: FrameStyle, - props: { - url: value - } - }) - }} - > - Read {LegalDocumentsDecorators[key] ?? `document (${key})`} - - }) - } + return ( + { + app.layout.modal.open(key, MarkdownReader, { + includeCloseButton: true, + frameContentStyle: FrameStyle, + props: { + url: value, + }, + }) + }} + > + Read {LegalDocumentsDecorators[key] ?? `document (${key})`} + + ) + })} - { - props.updateValue(event.target.checked) - }} - > - {composeConfirmationCheckboxLabel(legalDocuments)} - -
+ { + props.updateValue(event.target.checked) + }} + > + {composeConfirmationCheckboxLabel(legalDocuments)} + +
+ ) } export default { - key: "tos", - title: "Step 3", - icon: "FileDone", - description: "Take your time to read these legal documents.", - required: true, - content: TermsOfServiceStepComponent, + key: "tos", + title: "Step 3", + icon: "FileDone", + description: "Take your time to read these legal documents.", + required: true, + content: TermsOfServiceStepComponent, } diff --git a/packages/app/src/pages/auth/forms/selector/index.jsx b/packages/app/src/pages/auth/forms/selector/index.jsx index ebc27c96..e9cb927e 100755 --- a/packages/app/src/pages/auth/forms/selector/index.jsx +++ b/packages/app/src/pages/auth/forms/selector/index.jsx @@ -6,9 +6,14 @@ import { Icons } from "@components/Icons" const MainSelector = (props) => { return ( <> -
- -
+ {!app.isMobile && ( +
+ +
+ )}
{app.userData && ( diff --git a/packages/app/src/pages/auth/index.mobile.jsx b/packages/app/src/pages/auth/index.mobile.jsx index 3969df7e..bbf0f509 100755 --- a/packages/app/src/pages/auth/index.mobile.jsx +++ b/packages/app/src/pages/auth/index.mobile.jsx @@ -1,32 +1,55 @@ import React from "react" +import config from "@config" import useRandomFeaturedWallpaperUrl from "@hooks/useRandomFeaturedWallpaperUrl" +import useUrlQueryActiveKey from "@hooks/useUrlQueryActiveKey" + +import RegisterForm from "./forms/register" +import MainSelector from "./forms/selector" +import RecoveryForm from "./forms/recovery" + +const keyToComponents = { + selector: MainSelector, + register: RegisterForm, + recovery: RecoveryForm, +} import "./index.mobile.less" -export default (props) => { +const AuthPage = (props) => { const randomWallpaperURL = useRandomFeaturedWallpaperUrl() - - React.useEffect(() => { - if (app.userData) { - app.navigation.goMain() - } else { - app.auth.login() - } - }, []) + const [activeKey, setActiveKey] = useUrlQueryActiveKey({ + defaultKey: "selector", + }) return ( -
+
- {/*

- {wallpaperData?.author ? wallpaperData.author : null} -

*/} + /> + +
+
+ +
+ +
+ {React.createElement( + keyToComponents[activeKey] ?? keyToComponents["selector"], + { + setActiveKey: setActiveKey, + }, + )} +
) } + +export default AuthPage diff --git a/packages/app/src/pages/auth/index.mobile.less b/packages/app/src/pages/auth/index.mobile.less index b05a5d18..ea485231 100755 --- a/packages/app/src/pages/auth/index.mobile.less +++ b/packages/app/src/pages/auth/index.mobile.less @@ -1,22 +1,89 @@ -.loginPage { - position: relative; +.login-page { + position: relative; - width: 100%; - height: 100vh; - height: 100dvh; + display: flex; + flex-direction: column; - .wallpaper { - position: absolute; + align-items: center; + justify-content: center; - top: 0; - left: 0; + width: 100%; + height: 100vh; + height: 100dvh; - width: 100%; - height: 100vh; - height: 100dvh; + .wallpaper { + position: fixed; - background-position: center; - background-size: cover; - background-repeat: no-repeat; - } -} \ No newline at end of file + top: 0; + left: 0; + + z-index: -1; + + width: 100%; + height: 100vh; + height: 100dvh; + + background-position: center; + background-size: cover; + background-repeat: no-repeat; + } + + .login-page-card { + position: relative; + + display: flex; + flex-direction: column; + + width: 95%; + padding: 15px; + + background-color: var(--background-color-primary); + + border-radius: 24px; + + gap: 30px; + + &__header { + display: flex; + flex-direction: row; + + align-items: center; + + gap: 10px; + + &__logo { + width: 40px; + height: 40px; + } + } + + &__content { + display: flex; + flex-direction: column; + + .ant-btn { + height: fit-content; + padding: 7px 15px; + font-size: 0.8rem; + } + + .actions { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + + gap: 15px; + } + + .register_form { + position: unset; + + .register_form_actions { + top: 0; + bottom: unset; + } + } + } + } +} diff --git a/packages/app/src/pages/lyrics/components/text/index.jsx b/packages/app/src/pages/lyrics/components/text/index.jsx index 3fe85416..a2b3e3fd 100644 --- a/packages/app/src/pages/lyrics/components/text/index.jsx +++ b/packages/app/src/pages/lyrics/components/text/index.jsx @@ -104,6 +104,8 @@ const LyricsText = React.forwardRef((props, textRef) => { React.useEffect(() => { setVisible(false) setCurrentLineIndex(0) + // set scroll top to 0 + textRef.current.scrollTop = 0 }, [playerState.track_manifest]) React.useEffect(() => { diff --git a/packages/server/services/auth/auth.service.js b/packages/server/services/auth/auth.service.js index 1cb3f8aa..b0e008f8 100644 --- a/packages/server/services/auth/auth.service.js +++ b/packages/server/services/auth/auth.service.js @@ -35,7 +35,6 @@ export default class API extends Server { onExit() { this.queuesManager.cleanUp() - console.log("Jijija") } } diff --git a/packages/server/services/auth/classes/account/methods/create.js b/packages/server/services/auth/classes/account/methods/create.js index d64c280a..63478c75 100644 --- a/packages/server/services/auth/classes/account/methods/create.js +++ b/packages/server/services/auth/classes/account/methods/create.js @@ -1,60 +1,84 @@ import bcrypt from "bcrypt" import { User } from "@db_models" -import requiredFields from "@shared-utils/requiredFields" - import Account from "@classes/account" +import requiredFields from "@shared-utils/requiredFields" +import verifyTurnstileToken from "@utils/verifyTurnstileToken" + export default async (payload) => { - requiredFields(["username", "password", "email"], payload) + requiredFields(["username", "password", "email"], payload) - let { username, password, email, public_name, roles, avatar, accept_tos } = payload + let { + username, + password, + email, + public_name, + roles, + avatar, + accept_tos, + captcha, + } = payload - if (ToBoolean(accept_tos) !== true) { - throw new OperationError(400, "You must accept the terms of service in order to create an account.") - } + if (ToBoolean(accept_tos) !== true) { + throw new OperationError( + 400, + "You must accept the terms of service in order to create an account.", + ) + } - await Account.usernameMeetPolicy(username) + if (!captcha) { + throw new OperationError(400, "Captcha token is required") + } - // check if username is already taken - const existentUser = await User - .findOne({ username: username }) + const turnstileResponse = await verifyTurnstileToken(captcha) - if (existentUser) { - throw new OperationError(400, "User already exists") - } + if (turnstileResponse.success !== true) { + throw new OperationError(400, "Invalid captcha token") + } - // check if the email is already in use - const existentEmail = await User - .findOne({ email: email }) - .select("+email") + await Account.usernameMeetPolicy(username) - if (existentEmail) { - throw new OperationError(400, "Email already in use") - } + // check if username is already taken + const existentUser = await User.findOne({ username: username }) - await Account.passwordMeetPolicy(password) + if (existentUser) { + throw new OperationError(400, "User already exists") + } - // hash the password - const hash = bcrypt.hashSync(password, parseInt(process.env.BCRYPT_ROUNDS ?? 3)) + // check if the email is already in use + const existentEmail = await User.findOne({ email: email }).select("+email") - let user = new User({ - username: username, - password: hash, - email: email, - public_name: public_name, - avatar: avatar ?? `https://api.dicebear.com/7.x/thumbs/svg?seed=${username}`, - roles: roles, - created_at: new Date().getTime(), - accept_tos: accept_tos, - activated: false, - }) + if (existentEmail) { + throw new OperationError(400, "Email already in use") + } - await user.save() + await Account.passwordMeetPolicy(password) - await Account.sendActivationCode(user._id.toString()) + // hash the password + const hash = bcrypt.hashSync( + password, + parseInt(process.env.BCRYPT_ROUNDS ?? 3), + ) - return { - activation_required: true, - user: user, - } -} \ No newline at end of file + let user = new User({ + username: username, + password: hash, + email: email, + public_name: public_name, + avatar: + avatar ?? `https://api.dicebear.com/7.x/thumbs/svg?seed=${username}`, + roles: roles, + created_at: new Date().getTime(), + accept_tos: accept_tos, + activated: false, + }) + + await user.save() + + await Account.sendActivationCode(user._id.toString()) + + return { + activation_required: true, + user: user, + } +} diff --git a/packages/server/services/auth/utils/verifyTurnstileToken.js b/packages/server/services/auth/utils/verifyTurnstileToken.js new file mode 100644 index 00000000..693c52c1 --- /dev/null +++ b/packages/server/services/auth/utils/verifyTurnstileToken.js @@ -0,0 +1,26 @@ +import axios from "axios" + +export default async (token) => { + const secret = process.env.TURNSTILE_SECRET + + if (!secret) { + throw new Error("Turnstile secret is not set") + } + + let response = await axios({ + url: "https://challenges.cloudflare.com/turnstile/v0/siteverify", + method: "POST", + headers: { + "Content-Type": "application/json", + }, + data: { + secret: secret, + response: token, + }, + }).catch((err) => { + console.error(err.response.data) + throw new Error("Turnstile verification failed") + }) + + return response.data +}