Merge branch 'main' of https://github.com/MkSaaSHQ/mksaas-template into dev/ai-image
This commit is contained in:
commit
1a297e33f9
@ -25,20 +25,21 @@ If you found anything that could be improved, please let me know.
|
||||
|
||||
## Repositories
|
||||
|
||||
By default, you should have access to all four repositories. If you find that you’re unable to access any of them, please don’t hesitate to reach out to me, and I’ll assist you in resolving the issue.
|
||||
By default, you should have access to all 5 repositories. If you find that you’re unable to access any of them, please don’t hesitate to reach out to me, and I’ll assist you in resolving the issue.
|
||||
|
||||
- [mksaas-template (ready)](https://github.com/MkSaaSHQ/mksaas-template): https://demo.mksaas.com
|
||||
- [mksaas-blog (ready)](https://github.com/MkSaaSHQ/mksaas-blog): https://mksaas.me
|
||||
- [mksaas-haitang (ready)](https://github.com/MkSaaSHQ/mksaas-haitang): https://haitang.app
|
||||
- [mksaas-outfiai (ready)](https://github.com/MkSaaSHQ/mksaas-outfiai)
|
||||
- [mksaas-app (WIP)](https://github.com/MkSaaSHQ/mksaas-app): https://mksaas.app
|
||||
|
||||
## Notice
|
||||
|
||||
> If you have any questions, please [submit an issue](https://github.com/MkSaaSHQ/mksaas-template/issues/new), or contact me at [support@mksaas.com](mailto:support@mksaas.com).
|
||||
> If you have any questions, please [submit an issue](https://github.com/MkSaaSHQ/mksaas-template/issues/new), or contact me at [support@mksaas.com](mailto:support@mksaas.com), or join our [discord community](https://mksaas.link/discord) and ask for help there.
|
||||
|
||||
> If you want to receive notifications whenever code changes, please click `Watch` button in the top right.
|
||||
|
||||
> When submitting any content to the issues or discussions of the repository, please use **English** as the main Language, so that everyone can read it and help you, thank you for your supports.
|
||||
> When submitting any content to the issues of the repository, please use **English** as the main Language, so that everyone can read it and help you, thank you for your supports.
|
||||
|
||||
## License
|
||||
|
||||
|
@ -148,3 +148,10 @@ NEXT_PUBLIC_AFFILIATE_AFFONSO_ID=""
|
||||
# https://www.promotekit.com/
|
||||
# -----------------------------------------------------------------------------
|
||||
NEXT_PUBLIC_AFFILIATE_PROMOTEKIT_ID=""
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Captcha (Cloudflare Turnstile)
|
||||
# https://mksaas.com/docs/captcha#setup
|
||||
# -----------------------------------------------------------------------------
|
||||
NEXT_PUBLIC_TURNSTILE_SITE_KEY=""
|
||||
TURNSTILE_SECRET_KEY=""
|
||||
|
@ -202,7 +202,9 @@
|
||||
"hidePassword": "Hide password",
|
||||
"nameRequired": "Please enter your name",
|
||||
"emailRequired": "Please enter your email",
|
||||
"passwordRequired": "Please enter your password"
|
||||
"passwordRequired": "Please enter your password",
|
||||
"captchaInvalid": "Captcha verification failed",
|
||||
"captchaError": "Captcha verification error"
|
||||
},
|
||||
"forgotPassword": {
|
||||
"title": "Forgot Password",
|
||||
|
@ -203,7 +203,9 @@
|
||||
"hidePassword": "隐藏密码",
|
||||
"nameRequired": "请输入姓名",
|
||||
"emailRequired": "请输入邮箱",
|
||||
"passwordRequired": "请输入密码"
|
||||
"passwordRequired": "请输入密码",
|
||||
"captchaInvalid": "验证码验证失败",
|
||||
"captchaError": "验证码验证出错"
|
||||
},
|
||||
"forgotPassword": {
|
||||
"title": "忘记密码",
|
||||
|
@ -36,6 +36,7 @@
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@hookform/resolvers": "^4.1.0",
|
||||
"@marsidev/react-turnstile": "^1.1.0",
|
||||
"@next/third-parties": "^15.3.0",
|
||||
"@openpanel/nextjs": "^1.0.7",
|
||||
"@orama/orama": "^3.1.4",
|
||||
|
14
pnpm-lock.yaml
generated
14
pnpm-lock.yaml
generated
@ -44,6 +44,9 @@ importers:
|
||||
'@hookform/resolvers':
|
||||
specifier: ^4.1.0
|
||||
version: 4.1.0(react-hook-form@7.54.2(react@19.0.0))
|
||||
'@marsidev/react-turnstile':
|
||||
specifier: ^1.1.0
|
||||
version: 1.1.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
||||
'@next/third-parties':
|
||||
specifier: ^15.3.0
|
||||
version: 15.3.0(next@15.2.1(@babel/core@7.24.5)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)
|
||||
@ -1579,6 +1582,12 @@ packages:
|
||||
'@levischuck/tiny-cbor@0.2.11':
|
||||
resolution: {integrity: sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==}
|
||||
|
||||
'@marsidev/react-turnstile@1.1.0':
|
||||
resolution: {integrity: sha512-X7bP9ZYutDd+E+klPYF+/BJHqEyyVkN4KKmZcNRr84zs3DcMoftlMAuoKqNSnqg0HE7NQ1844+TLFSJoztCdSA==}
|
||||
peerDependencies:
|
||||
react: ^17.0.2 || ^18.0.0 || ^19.0
|
||||
react-dom: ^17.0.2 || ^18.0.0 || ^19.0
|
||||
|
||||
'@mdx-js/mdx@3.1.0':
|
||||
resolution: {integrity: sha512-/QxEhPAvGwbQmy1Px8F899L5Uc2KZ6JtXwlCgJmjSTBedwOZkByYcBG4GceIGPXRDsmfxhHazuS+hlOShRLeDw==}
|
||||
|
||||
@ -6738,6 +6747,11 @@ snapshots:
|
||||
|
||||
'@levischuck/tiny-cbor@0.2.11': {}
|
||||
|
||||
'@marsidev/react-turnstile@1.1.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
|
||||
dependencies:
|
||||
react: 19.0.0
|
||||
react-dom: 19.0.0(react@19.0.0)
|
||||
|
||||
'@mdx-js/mdx@3.1.0(acorn@8.14.0)':
|
||||
dependencies:
|
||||
'@types/estree': 1.0.6
|
||||
|
36
src/actions/validate-captcha.ts
Normal file
36
src/actions/validate-captcha.ts
Normal file
@ -0,0 +1,36 @@
|
||||
'use server';
|
||||
|
||||
import { validateTurnstileToken } from '@/lib/captcha';
|
||||
import { createSafeActionClient } from 'next-safe-action';
|
||||
import { z } from 'zod';
|
||||
|
||||
// Create a safe action client
|
||||
const actionClient = createSafeActionClient();
|
||||
|
||||
// Captcha validation schema
|
||||
const captchaSchema = z.object({
|
||||
captchaToken: z.string().min(1, { message: 'Captcha token is required' }),
|
||||
});
|
||||
|
||||
// Create a safe action for captcha validation
|
||||
export const validateCaptchaAction = actionClient
|
||||
.schema(captchaSchema)
|
||||
.action(async ({ parsedInput }) => {
|
||||
const { captchaToken } = parsedInput;
|
||||
|
||||
try {
|
||||
const isValid = await validateTurnstileToken(captchaToken);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
valid: isValid,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Captcha validation error:', error);
|
||||
return {
|
||||
success: false,
|
||||
valid: false,
|
||||
error: error instanceof Error ? error.message : 'Something went wrong',
|
||||
};
|
||||
}
|
||||
});
|
@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { validateCaptchaAction } from '@/actions/validate-captcha';
|
||||
import { AuthCard } from '@/components/auth/auth-card';
|
||||
import { FormError } from '@/components/shared/form-error';
|
||||
import { FormSuccess } from '@/components/shared/form-success';
|
||||
@ -15,6 +16,7 @@ import {
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { websiteConfig } from '@/config/website';
|
||||
import { authClient } from '@/lib/auth-client';
|
||||
import { isTurnstileEnabled } from '@/lib/captcha';
|
||||
import { getUrlWithLocaleInCallbackUrl } from '@/lib/urls/urls';
|
||||
import { DEFAULT_LOGIN_REDIRECT, Routes } from '@/routes';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
@ -22,8 +24,9 @@ import { EyeIcon, EyeOffIcon, Loader2Icon } from 'lucide-react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useForm, useWatch } from 'react-hook-form';
|
||||
import * as z from 'zod';
|
||||
import { Captcha } from '../shared/captcha';
|
||||
import { SocialLoginButton } from './social-login-button';
|
||||
|
||||
interface RegisterFormProps {
|
||||
@ -50,6 +53,12 @@ export const RegisterForm = ({
|
||||
const [isPending, setIsPending] = useState(false);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
// turnstile captcha schema
|
||||
const turnstileEnabled = isTurnstileEnabled();
|
||||
const captchaSchema = turnstileEnabled
|
||||
? z.string().min(1, 'Please complete the captcha')
|
||||
: z.string().optional();
|
||||
|
||||
const RegisterSchema = z.object({
|
||||
email: z.string().email({
|
||||
message: t('emailRequired'),
|
||||
@ -60,6 +69,7 @@ export const RegisterForm = ({
|
||||
name: z.string().min(1, {
|
||||
message: t('nameRequired'),
|
||||
}),
|
||||
captchaToken: captchaSchema,
|
||||
});
|
||||
|
||||
const form = useForm<z.infer<typeof RegisterSchema>>({
|
||||
@ -68,10 +78,30 @@ export const RegisterForm = ({
|
||||
email: '',
|
||||
password: '',
|
||||
name: '',
|
||||
captchaToken: '',
|
||||
},
|
||||
});
|
||||
|
||||
const captchaToken = useWatch({
|
||||
control: form.control,
|
||||
name: 'captchaToken',
|
||||
});
|
||||
|
||||
const onSubmit = async (values: z.infer<typeof RegisterSchema>) => {
|
||||
// Validate captcha token if turnstile is enabled
|
||||
if (turnstileEnabled && values.captchaToken) {
|
||||
const captchaResult = await validateCaptchaAction({
|
||||
captchaToken: values.captchaToken,
|
||||
});
|
||||
|
||||
if (!captchaResult?.data?.success || !captchaResult?.data?.valid) {
|
||||
console.error('register, captcha invalid:', values.captchaToken);
|
||||
const errorMessage = captchaResult?.data?.error || t('captchaInvalid');
|
||||
setError(errorMessage);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 1. if requireEmailVerification is true, callbackURL will be used in the verification email,
|
||||
// the user will be redirected to the callbackURL after the email is verified.
|
||||
// 2. if requireEmailVerification is false, the user will not be redirected to the callbackURL,
|
||||
@ -200,8 +230,12 @@ export const RegisterForm = ({
|
||||
</div>
|
||||
<FormError message={error} />
|
||||
<FormSuccess message={success} />
|
||||
<Captcha
|
||||
onSuccess={(token) => form.setValue('captchaToken', token)}
|
||||
validationError={form.formState.errors.captchaToken?.message}
|
||||
/>
|
||||
<Button
|
||||
disabled={isPending}
|
||||
disabled={isPending || (turnstileEnabled && !captchaToken)}
|
||||
size="lg"
|
||||
type="submit"
|
||||
className="cursor-pointer w-full flex items-center justify-center gap-2"
|
||||
|
48
src/components/shared/captcha.tsx
Normal file
48
src/components/shared/captcha.tsx
Normal file
@ -0,0 +1,48 @@
|
||||
'use client';
|
||||
|
||||
import { FormMessage } from '@/components/ui/form';
|
||||
import { isTurnstileEnabled } from '@/lib/captcha';
|
||||
import { useLocale } from 'next-intl';
|
||||
import { useTheme } from 'next-themes';
|
||||
import dynamic from 'next/dynamic';
|
||||
import type { ComponentProps } from 'react';
|
||||
|
||||
const Turnstile = dynamic(
|
||||
() => import('@marsidev/react-turnstile').then((mod) => mod.Turnstile),
|
||||
{
|
||||
ssr: false,
|
||||
}
|
||||
);
|
||||
|
||||
type Props = Omit<ComponentProps<typeof Turnstile>, 'siteKey'> & {
|
||||
validationError?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Captcha component for Cloudflare Turnstile
|
||||
*/
|
||||
export const Captcha = ({ validationError, ...props }: Props) => {
|
||||
const turnstileEnabled = isTurnstileEnabled();
|
||||
const theme = useTheme();
|
||||
const locale = useLocale();
|
||||
|
||||
return turnstileEnabled ? (
|
||||
<>
|
||||
<Turnstile
|
||||
options={{
|
||||
size: 'flexible',
|
||||
language: locale,
|
||||
theme: theme.theme === 'dark' ? 'dark' : 'light',
|
||||
}}
|
||||
{...props}
|
||||
siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY || ''}
|
||||
/>
|
||||
|
||||
{validationError && (
|
||||
<FormMessage className="text-red-500 mt-2">
|
||||
{validationError}
|
||||
</FormMessage>
|
||||
)}
|
||||
</>
|
||||
) : null;
|
||||
};
|
39
src/lib/captcha.ts
Normal file
39
src/lib/captcha.ts
Normal file
@ -0,0 +1,39 @@
|
||||
export function isTurnstileEnabled() {
|
||||
return (
|
||||
process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY !== '' &&
|
||||
process.env.TURNSTILE_SECRET_KEY !== ''
|
||||
);
|
||||
}
|
||||
|
||||
interface TurnstileResponse {
|
||||
success: boolean;
|
||||
'error-codes'?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* https://developers.cloudflare.com/turnstile/get-started/server-side-validation/
|
||||
*/
|
||||
export async function validateTurnstileToken(token: string) {
|
||||
const turnstileEnabled = isTurnstileEnabled();
|
||||
if (!turnstileEnabled) {
|
||||
console.log('validateTurnstileToken, turnstile is disabled');
|
||||
return true;
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
'https://challenges.cloudflare.com/turnstile/v0/siteverify',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
secret: process.env.TURNSTILE_SECRET_KEY,
|
||||
response: token,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
const data = (await response.json()) as TurnstileResponse;
|
||||
return data.success;
|
||||
}
|
Loading…
Reference in New Issue
Block a user