feat: integrate Turnstile captcha verification in signup process
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
Generated
+11
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user