Merge branch 'main' of https://github.com/MkSaaSHQ/mksaas-template into dev/ai-image

This commit is contained in:
javayhu 2025-06-28 10:12:23 +08:00
commit 1a297e33f9
10 changed files with 191 additions and 7 deletions

View File

@ -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 youre unable to access any of them, please dont hesitate to reach out to me, and Ill assist you in resolving the issue.
By default, you should have access to all 5 repositories. If you find that youre unable to access any of them, please dont hesitate to reach out to me, and Ill 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

View File

@ -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=""

View File

@ -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",

View File

@ -203,7 +203,9 @@
"hidePassword": "隐藏密码",
"nameRequired": "请输入姓名",
"emailRequired": "请输入邮箱",
"passwordRequired": "请输入密码"
"passwordRequired": "请输入密码",
"captchaInvalid": "验证码验证失败",
"captchaError": "验证码验证出错"
},
"forgotPassword": {
"title": "忘记密码",

View File

@ -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
View File

@ -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

View 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',
};
}
});

View File

@ -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"

View 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
View 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;
}