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 { z } from 'zod';
|
||||||
import { fetchPostSignin } from '../_http/fetch-post-signup';
|
import { fetchPostSignin } from '../_http/fetch-post-signup';
|
||||||
|
import { fetchPostverifyTurnstile } from '../_http/fetch-post-verify-turnstile';
|
||||||
import { signupValidationSchema } from '../_validation/signup-validation';
|
import { signupValidationSchema } from '../_validation/signup-validation';
|
||||||
|
|
||||||
export async function SignupAction(
|
export async function SignupAction(
|
||||||
@@ -9,7 +10,13 @@ export async function SignupAction(
|
|||||||
) {
|
) {
|
||||||
const validRequest = signupValidationSchema.parse(request);
|
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;
|
return data;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,9 +11,10 @@ import {
|
|||||||
Input,
|
Input,
|
||||||
} from '@components';
|
} from '@components';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { Turnstile, TurnstileInstance } from '@marsidev/react-turnstile';
|
||||||
import { useMutation } from '@tanstack/react-query';
|
import { useMutation } from '@tanstack/react-query';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useState } from 'react';
|
import { useRef, useState } from 'react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { LuLoader } from 'react-icons/lu';
|
import { LuLoader } from 'react-icons/lu';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
@@ -26,11 +27,31 @@ import {
|
|||||||
|
|
||||||
export function SignupForm() {
|
export function SignupForm() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const ref = useRef<TurnstileInstance | null>(null);
|
||||||
|
|
||||||
const [step, setStep] = useState(1);
|
const [step, setStep] = useState(1);
|
||||||
const [stepOneData, setStepOneData] = useState<z.infer<
|
const [stepOneData, setStepOneData] = useState<z.infer<
|
||||||
typeof stepOneSignupValidationSchema
|
typeof stepOneSignupValidationSchema
|
||||||
> | null>(null);
|
> | 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({
|
const { mutate, isPending, error } = useMutation({
|
||||||
mutationFn: async (data: z.infer<typeof signupValidationSchema>) => {
|
mutationFn: async (data: z.infer<typeof signupValidationSchema>) => {
|
||||||
const result = await SignupAction(data);
|
const result = await SignupAction(data);
|
||||||
@@ -44,27 +65,8 @@ export function SignupForm() {
|
|||||||
router.push(`/verification?ref=${email}`);
|
router.push(`/verification?ref=${email}`);
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
secondForm.reset();
|
ref.current?.reset();
|
||||||
},
|
secondForm.resetField('token');
|
||||||
});
|
|
||||||
|
|
||||||
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: '',
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -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
|
<FormField
|
||||||
control={firstForm.control}
|
control={firstForm.control}
|
||||||
name="password"
|
name="password"
|
||||||
@@ -169,62 +185,15 @@ export function SignupForm() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<FormField
|
<Turnstile
|
||||||
control={secondForm.control}
|
ref={ref}
|
||||||
name="phone_number"
|
siteKey={String(process.env.NEXT_PUBLIC_TURNSTILE_SITEKEY)}
|
||||||
render={({ field }) => (
|
onSuccess={(token) => secondForm.setValue('token', token)}
|
||||||
<FormItem>
|
options={{
|
||||||
<FormLabel>Nomor Telepon</FormLabel>
|
theme: 'light',
|
||||||
<FormControl>
|
size: 'flexible',
|
||||||
<Input placeholder="081234567890" {...field} />
|
language: 'id',
|
||||||
</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>
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
@@ -237,8 +206,7 @@ export function SignupForm() {
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isPending}
|
disabled={isPending || !secondForm.watch('token')}
|
||||||
className="flex items-center gap-2 hover:bg-[#5fbaef] bg-[#22a5f1]"
|
|
||||||
>
|
>
|
||||||
{isPending ? (
|
{isPending ? (
|
||||||
<LuLoader className="h-5 w-5 animate-spin" />
|
<LuLoader className="h-5 w-5 animate-spin" />
|
||||||
|
|||||||
@@ -6,9 +6,6 @@ export async function fetchPostSignin({
|
|||||||
password,
|
password,
|
||||||
fullname,
|
fullname,
|
||||||
phone_number,
|
phone_number,
|
||||||
student_type,
|
|
||||||
referral_code,
|
|
||||||
referred_by,
|
|
||||||
}: SignupValidationSchema) {
|
}: SignupValidationSchema) {
|
||||||
const { data, error, response } = await fetcher.POST('/v1/auth/register', {
|
const { data, error, response } = await fetcher.POST('/v1/auth/register', {
|
||||||
body: {
|
body: {
|
||||||
@@ -16,9 +13,6 @@ export async function fetchPostSignin({
|
|||||||
password,
|
password,
|
||||||
fullname,
|
fullname,
|
||||||
phone_number,
|
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'
|
'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
|
confirm_password: z
|
||||||
.string({
|
.string({
|
||||||
required_error: 'Konfirmasi password tidak boleh kosong',
|
required_error: 'Konfirmasi password tidak boleh kosong',
|
||||||
@@ -50,23 +58,7 @@ export const stepOneSignupValidationSchema = z
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const stepTwoSignupValidationSchema = z.object({
|
export const stepTwoSignupValidationSchema = z.object({
|
||||||
phone_number: z
|
token: z.string(),
|
||||||
.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(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const signupValidationSchema = stepOneSignupValidationSchema.and(
|
export const signupValidationSchema = stepOneSignupValidationSchema.and(
|
||||||
|
|||||||
@@ -2,5 +2,5 @@ import type { paths } from '@/openapi-types';
|
|||||||
import createClient from 'openapi-fetch';
|
import createClient from 'openapi-fetch';
|
||||||
|
|
||||||
export const fetcher = createClient<paths>({
|
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';
|
import createClient from 'openapi-react-query';
|
||||||
|
|
||||||
const fetchClient = createFetchClient<paths>({
|
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);
|
export const rpc = createClient(fetchClient);
|
||||||
|
|||||||
Generated
+11
@@ -12,6 +12,7 @@
|
|||||||
"@ant-design/icons": "^6.0.0",
|
"@ant-design/icons": "^6.0.0",
|
||||||
"@hookform/resolvers": "^5.0.1",
|
"@hookform/resolvers": "^5.0.1",
|
||||||
"@iconify/react": "^6.0.0",
|
"@iconify/react": "^6.0.0",
|
||||||
|
"@marsidev/react-turnstile": "^1.1.0",
|
||||||
"@radix-ui/react-slot": "^1.2.0",
|
"@radix-ui/react-slot": "^1.2.0",
|
||||||
"@redocly/ajv": "^8.11.2",
|
"@redocly/ajv": "^8.11.2",
|
||||||
"@tanstack/react-query": "^5.74.4",
|
"@tanstack/react-query": "^5.74.4",
|
||||||
@@ -4161,6 +4162,16 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@mdx-js/react": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@mdx-js/react/-/react-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@mdx-js/react/-/react-3.1.0.tgz",
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
"@ant-design/icons": "^6.0.0",
|
"@ant-design/icons": "^6.0.0",
|
||||||
"@hookform/resolvers": "^5.0.1",
|
"@hookform/resolvers": "^5.0.1",
|
||||||
"@iconify/react": "^6.0.0",
|
"@iconify/react": "^6.0.0",
|
||||||
|
"@marsidev/react-turnstile": "^1.1.0",
|
||||||
"@radix-ui/react-slot": "^1.2.0",
|
"@radix-ui/react-slot": "^1.2.0",
|
||||||
"@redocly/ajv": "^8.11.2",
|
"@redocly/ajv": "^8.11.2",
|
||||||
"@tanstack/react-query": "^5.74.4",
|
"@tanstack/react-query": "^5.74.4",
|
||||||
|
|||||||
Reference in New Issue
Block a user