feat: integrate Turnstile captcha verification in signup process

This commit is contained in:
arraysid
2025-05-27 13:49:02 +07:00
parent fc3c275fea
commit 761eb0963a
10 changed files with 122 additions and 107 deletions
+2 -1
View File
@@ -1 +1,2 @@
NEXT_PUBLIC_API_URL=
NEXT_PUBLIC_API_URL=
NEXT_PUBLIC_TURNSTILE_SITEKEY=1x00000000000000000000AA
@@ -2,6 +2,7 @@
import { z } from 'zod';
import { fetchPostSignin } from '../_http/fetch-post-signup';
import { fetchPostverifyTurnstile } from '../_http/fetch-post-verify-turnstile';
import { signupValidationSchema } from '../_validation/signup-validation';
export async function SignupAction(
@@ -9,7 +10,13 @@ export async function SignupAction(
) {
const validRequest = signupValidationSchema.parse(request);
const data = await fetchPostSignin({ ...validRequest });
const isCapchaValidationValid = await fetchPostverifyTurnstile(
validRequest.token
);
if (!isCapchaValidationValid) throw new Error('Failed to verify captcha');
const data = await fetchPostSignin(validRequest);
return data;
}
@@ -11,9 +11,10 @@ import {
Input,
} from '@components';
import { zodResolver } from '@hookform/resolvers/zod';
import { Turnstile, TurnstileInstance } from '@marsidev/react-turnstile';
import { useMutation } from '@tanstack/react-query';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { useRef, useState } from 'react';
import { useForm } from 'react-hook-form';
import { LuLoader } from 'react-icons/lu';
import { z } from 'zod';
@@ -26,11 +27,31 @@ import {
export function SignupForm() {
const router = useRouter();
const ref = useRef<TurnstileInstance | null>(null);
const [step, setStep] = useState(1);
const [stepOneData, setStepOneData] = useState<z.infer<
typeof stepOneSignupValidationSchema
> | null>(null);
const firstForm = useForm<z.infer<typeof stepOneSignupValidationSchema>>({
resolver: zodResolver(stepOneSignupValidationSchema),
defaultValues: {
email: '',
phone_number: '',
fullname: '',
password: '',
confirm_password: '',
},
});
const secondForm = useForm<z.infer<typeof stepTwoSignupValidationSchema>>({
resolver: zodResolver(stepTwoSignupValidationSchema),
defaultValues: {
token: '',
},
});
const { mutate, isPending, error } = useMutation({
mutationFn: async (data: z.infer<typeof signupValidationSchema>) => {
const result = await SignupAction(data);
@@ -44,27 +65,8 @@ export function SignupForm() {
router.push(`/verification?ref=${email}`);
},
onError: () => {
secondForm.reset();
},
});
const firstForm = useForm<z.infer<typeof stepOneSignupValidationSchema>>({
resolver: zodResolver(stepOneSignupValidationSchema),
defaultValues: {
email: '',
fullname: '',
password: '',
confirm_password: '',
},
});
const secondForm = useForm<z.infer<typeof stepTwoSignupValidationSchema>>({
resolver: zodResolver(stepTwoSignupValidationSchema),
defaultValues: {
phone_number: '',
referral_code: '',
referred_by: '',
student_type: '',
ref.current?.reset();
secondForm.resetField('token');
},
});
@@ -119,6 +121,20 @@ export function SignupForm() {
)}
/>
<FormField
control={firstForm.control}
name="phone_number"
render={({ field }) => (
<FormItem>
<FormLabel>Nomor Telepon</FormLabel>
<FormControl>
<Input placeholder="08123456789" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={firstForm.control}
name="password"
@@ -169,62 +185,15 @@ export function SignupForm() {
</div>
)}
<FormField
control={secondForm.control}
name="phone_number"
render={({ field }) => (
<FormItem>
<FormLabel>Nomor Telepon</FormLabel>
<FormControl>
<Input placeholder="081234567890" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex gap-x-4">
<FormField
control={secondForm.control}
name="referral_code"
render={({ field }) => (
<FormItem>
<FormLabel>Kode Referral</FormLabel>
<FormControl>
<Input placeholder="ABCD" maxLength={4} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={secondForm.control}
name="referred_by"
render={({ field }) => (
<FormItem>
<FormLabel>Referrer</FormLabel>
<FormControl>
<Input placeholder="Referral ID" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={secondForm.control}
name="student_type"
render={({ field }) => (
<FormItem>
<FormLabel>Status</FormLabel>
<FormControl>
<Input placeholder="e.g., Undergraduate" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
<Turnstile
ref={ref}
siteKey={String(process.env.NEXT_PUBLIC_TURNSTILE_SITEKEY)}
onSuccess={(token) => secondForm.setValue('token', token)}
options={{
theme: 'light',
size: 'flexible',
language: 'id',
}}
/>
<div className="flex justify-between">
@@ -237,8 +206,7 @@ export function SignupForm() {
</Button>
<Button
type="submit"
disabled={isPending}
className="flex items-center gap-2 hover:bg-[#5fbaef] bg-[#22a5f1]"
disabled={isPending || !secondForm.watch('token')}
>
{isPending ? (
<LuLoader className="h-5 w-5 animate-spin" />
@@ -6,9 +6,6 @@ export async function fetchPostSignin({
password,
fullname,
phone_number,
student_type,
referral_code,
referred_by,
}: SignupValidationSchema) {
const { data, error, response } = await fetcher.POST('/v1/auth/register', {
body: {
@@ -16,9 +13,6 @@ export async function fetchPostSignin({
password,
fullname,
phone_number,
student_type,
referral_code,
referred_by,
},
});
@@ -0,0 +1,41 @@
export async function fetchPostverifyTurnstile(
token: string,
remoteIp?: string
): Promise<boolean> {
const url = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
const params = new URLSearchParams({
secret: String(process.env.TURNSTILE_SECRET_KEY),
response: token,
});
if (remoteIp) {
params.append('remoteip', remoteIp);
}
const res = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: params.toString(),
});
if (!res.ok) {
console.error('Turnstile verify HTTP error', res.status);
return false;
}
const data = (await res.json()) as TurnstileVerifyResponse;
if (!data.success) {
console.warn('Turnstile failure', data['error-codes']);
return false;
}
return true;
}
interface TurnstileVerifyResponse {
success: boolean;
challenge_ts: string;
hostname: string;
'error-codes'?: string[];
}
@@ -37,6 +37,14 @@ export const stepOneSignupValidationSchema = z
/(?=.*[!@#$%^&*()_\-+={}[\]|\\:;"'<>,.?/~`])/,
'Password harus mengandung setidaknya satu karakter spesial'
),
phone_number: z
.string({
required_error: 'Nomor telepon tidak boleh kosong',
invalid_type_error: 'Nomor telepon harus berupa string',
})
.min(10, 'Nomor telepon tidak boleh kurang dari 10 karakter')
.max(15, 'Nomor telepon tidak boleh lebih dari 15 karakter')
.regex(/^\d+$/, 'Nomor telepon hanya boleh berisi angka'),
confirm_password: z
.string({
required_error: 'Konfirmasi password tidak boleh kosong',
@@ -50,23 +58,7 @@ export const stepOneSignupValidationSchema = z
});
export const stepTwoSignupValidationSchema = z.object({
phone_number: z
.string({
required_error: 'Nomor telepon tidak boleh kosong',
invalid_type_error: 'Nomor telepon harus berupa string',
})
.min(10, 'Nomor telepon tidak boleh kurang dari 10 karakter')
.max(15, 'Nomor telepon tidak boleh lebih dari 15 karakter')
.regex(/^\d+$/, 'Nomor telepon hanya boleh berisi angka'),
referral_code: z
.string()
.max(4, 'Kode referral tidak boleh lebih dari 4 karakter')
.optional(),
referred_by: z
.string()
.max(50, 'Kode referral tidak boleh lebih dari 50 karakter')
.optional(),
student_type: z.string(),
token: z.string(),
});
export const signupValidationSchema = stepOneSignupValidationSchema.and(
+1 -1
View File
@@ -2,5 +2,5 @@ import type { paths } from '@/openapi-types';
import createClient from 'openapi-fetch';
export const fetcher = createClient<paths>({
baseUrl: process.env.NEXT_PUBLIC_API_URL ?? 'https://api.imphnen.dev',
baseUrl: process.env.NEXT_PUBLIC_API_URL,
});
+1 -1
View File
@@ -3,6 +3,6 @@ import createFetchClient from 'openapi-fetch';
import createClient from 'openapi-react-query';
const fetchClient = createFetchClient<paths>({
baseUrl: process.env.NEXT_PUBLIC_API_URL ?? 'https://api.imphnen.dev',
baseUrl: process.env.NEXT_PUBLIC_API_URL,
});
export const rpc = createClient(fetchClient);
+11
View File
@@ -12,6 +12,7 @@
"@ant-design/icons": "^6.0.0",
"@hookform/resolvers": "^5.0.1",
"@iconify/react": "^6.0.0",
"@marsidev/react-turnstile": "^1.1.0",
"@radix-ui/react-slot": "^1.2.0",
"@redocly/ajv": "^8.11.2",
"@tanstack/react-query": "^5.74.4",
@@ -4161,6 +4162,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/@marsidev/react-turnstile": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@marsidev/react-turnstile/-/react-turnstile-1.1.0.tgz",
"integrity": "sha512-X7bP9ZYutDd+E+klPYF+/BJHqEyyVkN4KKmZcNRr84zs3DcMoftlMAuoKqNSnqg0HE7NQ1844+TLFSJoztCdSA==",
"license": "MIT",
"peerDependencies": {
"react": "^17.0.2 || ^18.0.0 || ^19.0",
"react-dom": "^17.0.2 || ^18.0.0 || ^19.0"
}
},
"node_modules/@mdx-js/react": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@mdx-js/react/-/react-3.1.0.tgz",
+1
View File
@@ -28,6 +28,7 @@
"@ant-design/icons": "^6.0.0",
"@hookform/resolvers": "^5.0.1",
"@iconify/react": "^6.0.0",
"@marsidev/react-turnstile": "^1.1.0",
"@radix-ui/react-slot": "^1.2.0",
"@redocly/ajv": "^8.11.2",
"@tanstack/react-query": "^5.74.4",