From 61b650bb251b02f64bf1e912aed479d510ad7972 Mon Sep 17 00:00:00 2001 From: maulanasdqn Date: Fri, 10 Apr 2026 19:47:03 +0700 Subject: [PATCH] feat: add react-hook-form + zod validation to all login forms All login pages now use react-hook-form with zod resolver for real-time validation: - Email validated as proper email format on each keystroke - Password required validation - Red border + error message shown inline below invalid fields - Submit button disabled until all fields are valid - Input type="text" instead of type="email" to avoid browser tooltip Also fixed: - Gacha API trailing slash on /items/ and /credits/ causing 404s Apps updated: hackathon, dimentorin, backoffice, qrcampaign, gacha Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/app/(public)/auth/login/page.tsx | 75 +++----- apps/dimentorin/src/app/auth/login/page.tsx | 134 ++++---------- .../app/_components/form/modal-form-login.tsx | 90 +++------ apps/hackathon/src/app/auth/login/page.tsx | 174 +++++------------- apps/qrcampaign/src/app/auth/login/page.tsx | 154 +++++----------- libs/service/src/api/gacha/index.ts | 4 +- 6 files changed, 180 insertions(+), 451 deletions(-) diff --git a/apps/backoffice/src/app/(public)/auth/login/page.tsx b/apps/backoffice/src/app/(public)/auth/login/page.tsx index 687d9f4..0c3b03a 100644 --- a/apps/backoffice/src/app/(public)/auth/login/page.tsx +++ b/apps/backoffice/src/app/(public)/auth/login/page.tsx @@ -1,43 +1,40 @@ import { useState } from 'react'; -import { useLogin } from '@imphnen-frontend-service/service'; +import { useLogin, authLoginSchema, TLoginRequest } from '@imphnen-frontend-service/service'; import { useNavigate } from 'react-router'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; import { toast } from 'sonner'; import { Icon } from '@iconify/react'; export default function LoginPage() { const navigate = useNavigate(); const loginMutation = useLogin(); - const [email, setEmail] = useState(''); - const [password, setPassword] = useState(''); - const [error, setError] = useState(null); const [showPassword, setShowPassword] = useState(false); + const [error, setError] = useState(null); - const handleEmailLogin = async (e: React.FormEvent) => { - e.preventDefault(); + const { register, handleSubmit, formState: { errors, isValid } } = useForm({ + resolver: zodResolver(authLoginSchema), + mode: 'onChange', + defaultValues: { email: '', password: '' }, + }); + + const onSubmit = handleSubmit(async (data) => { setError(null); - if (!email || !password) { - setError('Please enter both email and password'); - return; - } try { - await loginMutation.mutateAsync({ email, password }); + await loginMutation.mutateAsync(data); toast.success('Login successful!'); navigate('/'); } catch (err) { setError((err as Error).message || 'Login failed'); } - }; + }); return (
-

- Welcome Back -

-

- Sign in to IMPHNEN Backoffice -

+

Welcome Back

+

Sign in to IMPHNEN Backoffice

{error && ( @@ -46,63 +43,49 @@ export default function LoginPage() {
)} -
+
- + setEmail(e.target.value)} + type="text" + {...register('email')} placeholder="your@email.com" disabled={loginMutation.isPending} - className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white text-gray-900 placeholder:text-gray-400 disabled:bg-gray-100 disabled:cursor-not-allowed" - required + className={`w-full px-4 py-2.5 border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white text-gray-900 placeholder:text-gray-400 disabled:bg-gray-100 disabled:cursor-not-allowed ${errors.email ? 'border-red-400' : 'border-gray-300'}`} /> + {errors.email &&

{errors.email.message}

}
-
- -
+
setPassword(e.target.value)} + {...register('password')} placeholder="••••••••" disabled={loginMutation.isPending} - className="w-full px-4 py-2.5 pr-12 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white text-gray-900 placeholder:text-gray-400 disabled:bg-gray-100 disabled:cursor-not-allowed" - required + className={`w-full px-4 py-2.5 pr-12 border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white text-gray-900 placeholder:text-gray-400 disabled:bg-gray-100 disabled:cursor-not-allowed ${errors.password ? 'border-red-400' : 'border-gray-300'}`} /> -
+ {errors.password &&

{errors.password.message}

}
-

- By signing in, you agree to our Terms of Service and Privacy Policy -

+

By signing in, you agree to our Terms of Service and Privacy Policy

diff --git a/apps/dimentorin/src/app/auth/login/page.tsx b/apps/dimentorin/src/app/auth/login/page.tsx index a6398f8..fe78e61 100644 --- a/apps/dimentorin/src/app/auth/login/page.tsx +++ b/apps/dimentorin/src/app/auth/login/page.tsx @@ -1,10 +1,9 @@ import { useState, useEffect } from 'react'; -import { - useGitHubAuth, - useLogin, -} from '@imphnen-frontend-service/service'; +import { useGitHubAuth, useLogin, authLoginSchema, TLoginRequest } from '@imphnen-frontend-service/service'; import { GithubOutlined } from '@ant-design/icons'; import { useNavigate, Link } from 'react-router'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; import { toast } from 'sonner'; import { Icon } from '@iconify/react'; @@ -13,50 +12,43 @@ export default function LoginPage() { const { signInWithGitHub } = useGitHubAuth(); const loginMutation = useLogin(); const [isGithubLoading, setIsGithubLoading] = useState(false); - const [email, setEmail] = useState(''); - const [password, setPassword] = useState(''); const [error, setError] = useState(null); const [showPassword, setShowPassword] = useState(false); + const { register, handleSubmit, formState: { errors, isValid } } = useForm({ + resolver: zodResolver(authLoginSchema), + mode: 'onChange', + defaultValues: { email: '', password: '' }, + }); + useEffect(() => { const hashParams = new URLSearchParams(globalThis.location.hash.substring(1)); const urlParams = new URLSearchParams(globalThis.location.search); const accessToken = hashParams.get('access_token') || urlParams.get('access_token'); const type = hashParams.get('type') || urlParams.get('type'); - if (accessToken) { - if (type === 'recovery' || type === 'magiclink' || !type) { - toast.info('Redirecting to password reset...'); - navigate('/auth/reset-password?access_token=' + accessToken); - } + if (accessToken && (type === 'recovery' || type === 'magiclink' || !type)) { + toast.info('Redirecting to password reset...'); + navigate('/auth/reset-password?access_token=' + accessToken); } }, [navigate]); - const handleEmailLogin = async (e: React.FormEvent) => { - e.preventDefault(); + const onSubmit = handleSubmit(async (data) => { setError(null); - if (!email || !password) { - setError('Please enter both email and password'); - return; - } try { - await loginMutation.mutateAsync({ email, password }); + await loginMutation.mutateAsync(data); toast.success('Login successful!'); navigate('/dashboard'); } catch (err) { setError((err as Error).message || 'Login failed'); } - }; + }); const handleGithubLogin = async () => { try { setIsGithubLoading(true); const result = await signInWithGitHub(); - if (result?.url) { - globalThis.location.href = result.url; - } else { - setIsGithubLoading(false); - setError('Failed to get GitHub OAuth URL'); - } + if (result?.url) globalThis.location.href = result.url; + else { setIsGithubLoading(false); setError('Failed to get GitHub OAuth URL'); } } catch (err) { setError((err as Error).message || 'GitHub login failed'); setIsGithubLoading(false); @@ -67,22 +59,15 @@ export default function LoginPage() {
-
-

- Welcome Back -

-

- Sign in to the mentoring platform -

+

Welcome Back

+

Sign in to the mentoring platform

{error && ( @@ -91,58 +76,31 @@ export default function LoginPage() {
)} -
+
- - setEmail(e.target.value)} - placeholder="your@email.com" - disabled={loginMutation.isPending} - className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white text-gray-900 placeholder:text-gray-400 disabled:bg-gray-100 disabled:cursor-not-allowed" - required - /> + + + {errors.email &&

{errors.email.message}

}
- - - Forgot password? - + + Forgot password?
- setPassword(e.target.value)} - placeholder="••••••••" - disabled={loginMutation.isPending} - className="w-full px-4 py-2.5 pr-12 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white text-gray-900 placeholder:text-gray-400 disabled:bg-gray-100 disabled:cursor-not-allowed" - required - /> -
+ {errors.password &&

{errors.password.message}

}
-
@@ -153,37 +111,21 @@ export default function LoginPage() {
-

- Make sure your GitHub email is{' '} - - set to public - {' '} - for GitHub sign in to work. + Make sure your GitHub email is set to public for GitHub sign in to work.

-

- Don't have an account?{' '} - - Sign up - -

+

Don't have an account? Sign up

-
-

- By signing in, you agree to our Terms of Service and Privacy Policy -

+

By signing in, you agree to our Terms of Service and Privacy Policy

diff --git a/apps/gacha/src/app/_components/form/modal-form-login.tsx b/apps/gacha/src/app/_components/form/modal-form-login.tsx index a290a23..c136291 100644 --- a/apps/gacha/src/app/_components/form/modal-form-login.tsx +++ b/apps/gacha/src/app/_components/form/modal-form-login.tsx @@ -1,8 +1,11 @@ import { useState } from 'react'; import { Modal } from '@imphnen-frontend-service/ui/molecules'; import { useLogin } from '../../_hooks/use-login'; +import { authLoginSchema, TLoginRequest } from '@imphnen-frontend-service/service'; import ModalFormVerifyEmail from './modal-form-verify-email'; import { Icon } from '@iconify/react'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; interface IModalFormLogin { isOpen: boolean; @@ -18,8 +21,7 @@ const ModalFormLogin = ({ setIsOpenRegisterModal, }: IModalFormLogin) => { const { - form, - onSubmit, + onSubmit: originalOnSubmit, isLoading, showVerifyModal, verifyForm, @@ -31,8 +33,15 @@ const ModalFormLogin = ({ const [showPassword, setShowPassword] = useState(false); - const email = form.watch('email'); - const password = form.watch('password'); + const { register, handleSubmit, formState: { errors, isValid } } = useForm({ + resolver: zodResolver(authLoginSchema), + mode: 'onChange', + defaultValues: { email: '', password: '' }, + }); + + const onSubmit = handleSubmit(() => { + originalOnSubmit(); + }); return ( <> @@ -43,86 +52,43 @@ const ModalFormLogin = ({ >
-

- Welcome Back -

-

- Sign in to IMPHNEN Gacha -

+

Welcome Back

+

Sign in to IMPHNEN Gacha

- - - {form.formState.errors.email && ( -

{form.formState.errors.email.message}

- )} + + + {errors.email &&

{errors.email.message}

}
- - + +
- -
- {form.formState.errors.password && ( -

{form.formState.errors.password.message}

- )} + {errors.password &&

{errors.password.message}

}
-

Don't have an account?{' '} - +

diff --git a/apps/hackathon/src/app/auth/login/page.tsx b/apps/hackathon/src/app/auth/login/page.tsx index 9f962cf..4b2a6af 100644 --- a/apps/hackathon/src/app/auth/login/page.tsx +++ b/apps/hackathon/src/app/auth/login/page.tsx @@ -2,9 +2,13 @@ import { useState, useEffect } from 'react'; import { useGitHubAuth, useLogin, + authLoginSchema, + TLoginRequest, } from '@imphnen-frontend-service/service'; import { GithubOutlined } from '@ant-design/icons'; import { useNavigate, Link } from 'react-router'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; import { toast } from 'sonner'; import { Icon } from '@iconify/react'; import { ThemeToggle } from '../../../components/theme-toggle'; @@ -14,67 +18,44 @@ export default function LoginPage() { const { signInWithGitHub } = useGitHubAuth(); const loginMutation = useLogin(); const [isGithubLoading, setIsGithubLoading] = useState(false); - const [email, setEmail] = useState(''); - const [password, setPassword] = useState(''); const [error, setError] = useState(null); const [showPassword, setShowPassword] = useState(false); + const { register, handleSubmit, formState: { errors, isValid } } = useForm({ + resolver: zodResolver(authLoginSchema), + mode: 'onChange', + defaultValues: { email: '', password: '' }, + }); + useEffect(() => { const hashParams = new URLSearchParams(globalThis.location.hash.substring(1)); const urlParams = new URLSearchParams(globalThis.location.search); - const accessToken = hashParams.get('access_token') || urlParams.get('access_token'); const type = hashParams.get('type') || urlParams.get('type'); - - if (accessToken) { - console.log('[Login] Detected access_token, redirecting to reset-password page'); - - if (type === 'recovery' || type === 'magiclink' || !type) { - toast.info('Redirecting to password reset...'); - navigate('/auth/reset-password?access_token=' + accessToken); - } + if (accessToken && (type === 'recovery' || type === 'magiclink' || !type)) { + toast.info('Redirecting to password reset...'); + navigate('/auth/reset-password?access_token=' + accessToken); } }, [navigate]); - const handleEmailLogin = async (e: React.FormEvent) => { - e.preventDefault(); + const onSubmit = handleSubmit(async (data) => { setError(null); - - if (!email || !password) { - setError('Please enter both email and password'); - return; - } - try { - const result = await loginMutation.mutateAsync({ email, password }); - + const result = await loginMutation.mutateAsync(data); toast.success('Login successful!'); - - if (result.user.location) { - navigate('/dashboard'); - } else { - navigate('/onboarding/user'); - } + navigate(result.user.location ? '/dashboard' : '/onboarding/user'); } catch (err) { - console.error('[Login] Email login failed:', err); setError((err as Error).message || 'Login failed'); } - }; + }); const handleGithubLogin = async () => { try { setIsGithubLoading(true); - const result = await signInWithGitHub(); - - if (result?.url) { - globalThis.location.href = result.url; - } else { - setIsGithubLoading(false); - setError('Failed to get GitHub OAuth URL'); - } + if (result?.url) globalThis.location.href = result.url; + else { setIsGithubLoading(false); setError('Failed to get GitHub OAuth URL'); } } catch (err) { - console.error('[Login] GitHub login failed:', err); setError((err as Error).message || 'GitHub login failed'); setIsGithubLoading(false); } @@ -84,10 +65,7 @@ export default function LoginPage() {
- @@ -95,12 +73,8 @@ export default function LoginPage() {
-

- Welcome Back -

-

- Sign in to join or create your hackathon team -

+

Welcome Back

+

Sign in to join or create your hackathon team

{error && ( @@ -109,120 +83,56 @@ export default function LoginPage() {
)} -
+
- - setEmail(e.target.value)} - placeholder="your@email.com" - disabled={loginMutation.isPending} - className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white text-gray-900 placeholder:text-gray-400 disabled:bg-gray-100 disabled:cursor-not-allowed" - required - /> + + + {errors.email &&

{errors.email.message}

}
- - - Forgot password? - + + Forgot password?
- setPassword(e.target.value)} - placeholder="••••••••" - disabled={loginMutation.isPending} - className="w-full px-4 py-2.5 pr-12 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white text-gray-900 placeholder:text-gray-400 disabled:bg-gray-100 disabled:cursor-not-allowed" - required - /> -
+ {errors.password &&

{errors.password.message}

}
-
- - OR - + OR
-

- Make sure your GitHub email is{' '} - - set to public - {' '} - for GitHub sign in to work. + Make sure your GitHub email is set to public for GitHub sign in to work.

-

- Don't have an account?{' '} - - Sign up - -

+

Don't have an account? Sign up

-
-

- By signing in, you agree to our Terms of Service and Privacy Policy -

+

By signing in, you agree to our Terms of Service and Privacy Policy

diff --git a/apps/qrcampaign/src/app/auth/login/page.tsx b/apps/qrcampaign/src/app/auth/login/page.tsx index 8e852f2..32b237f 100644 --- a/apps/qrcampaign/src/app/auth/login/page.tsx +++ b/apps/qrcampaign/src/app/auth/login/page.tsx @@ -4,75 +4,50 @@ import { useNavigate, Link } from 'react-router'; import { toast } from 'sonner'; import { Icon } from '@iconify/react'; import { useAuthStore } from '../../features/auth/store/auth.store'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; + +const loginSchema = z.object({ + email: z.string().min(1, 'Email is required').email('Please enter a valid email'), + password: z.string().min(1, 'Password is required'), +}); +type LoginForm = z.infer; export default function LoginPage() { const navigate = useNavigate(); const login = useAuthStore((state) => state.login); const isAuthenticated = useAuthStore((state) => state.isAuthenticated); - const [isGithubLoading, setIsGithubLoading] = useState(false); - const [email, setEmail] = useState(''); - const [password, setPassword] = useState(''); const [error, setError] = useState(null); const [showPassword, setShowPassword] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); - useEffect(() => { - if (isAuthenticated) { - navigate('/'); - } - }, [isAuthenticated, navigate]); + const { register, handleSubmit, formState: { errors, isValid } } = useForm({ + resolver: zodResolver(loginSchema), + mode: 'onChange', + defaultValues: { email: '', password: '' }, + }); - useEffect(() => { - const hashParams = new URLSearchParams(globalThis.location.hash.substring(1)); - const urlParams = new URLSearchParams(globalThis.location.search); - const accessToken = hashParams.get('access_token') || urlParams.get('access_token'); - const type = hashParams.get('type') || urlParams.get('type'); - if (accessToken) { - if (type === 'recovery' || type === 'magiclink' || !type) { - toast.info('Redirecting to password reset...'); - navigate('/auth/reset-password?access_token=' + accessToken); - } - } - }, [navigate]); + useEffect(() => { if (isAuthenticated) navigate('/'); }, [isAuthenticated, navigate]); - const handleEmailLogin = async (e: React.FormEvent) => { - e.preventDefault(); + const onSubmit = handleSubmit(async (data) => { setError(null); - if (!email || !password) { - setError('Please enter both email and password'); - return; - } setIsSubmitting(true); try { - const success = await login(email, password); - if (success) { - toast.success('Login successful!'); - navigate('/'); - } else { - setError('Login failed. Please check your credentials.'); - } - } catch (err) { - setError('Login failed. Please try again.'); - } finally { - setIsSubmitting(false); - } - }; - - const handleGithubLogin = async () => { - toast.info('GitHub login coming soon'); - }; + const success = await login(data.email, data.password); + if (success) { toast.success('Login successful!'); navigate('/'); } + else setError('Login failed. Please check your credentials.'); + } catch { setError('Login failed. Please try again.'); } + finally { setIsSubmitting(false); } + }); return (
-

- Welcome Back -

-

- Sign in to QR Campaign Manager -

+

Welcome Back

+

Sign in to QR Campaign Manager

{error && ( @@ -81,58 +56,31 @@ export default function LoginPage() {
)} -
+
- - setEmail(e.target.value)} - placeholder="your@email.com" - disabled={isSubmitting} - className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white text-gray-900 placeholder:text-gray-400 disabled:bg-gray-100 disabled:cursor-not-allowed" - required - /> + + + {errors.email &&

{errors.email.message}

}
- - - Forgot password? - + + Forgot password?
- setPassword(e.target.value)} - placeholder="••••••••" - disabled={isSubmitting} - className="w-full px-4 py-2.5 pr-12 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white text-gray-900 placeholder:text-gray-400 disabled:bg-gray-100 disabled:cursor-not-allowed" - required - /> -
+ {errors.password &&

{errors.password.message}

}
-
@@ -143,37 +91,17 @@ export default function LoginPage() {
- -

- Make sure your GitHub email is{' '} - - set to public - {' '} - for GitHub sign in to work. -

-
-

- Don't have an account?{' '} - - Sign up - -

+

Don't have an account? Sign up

-
-

- By signing in, you agree to our Terms of Service and Privacy Policy -

+

By signing in, you agree to our Terms of Service and Privacy Policy

diff --git a/libs/service/src/api/gacha/index.ts b/libs/service/src/api/gacha/index.ts index 47fd58e..208ade6 100644 --- a/libs/service/src/api/gacha/index.ts +++ b/libs/service/src/api/gacha/index.ts @@ -14,7 +14,7 @@ import type { TApiPaginated, TPaginationParams } from '../../types/common'; // ----- Credits ----- export const getUserCredits = async (): Promise => { - const response = await api.get>('/v1/gacha/credits/'); + const response = await api.get>('/v1/gacha/credits'); return response.data.data; }; @@ -30,7 +30,7 @@ export const consumeCredit = async (): Promise<{ message: string }> => { // ----- Items ----- export const getGachaItemList = async (params?: TPaginationParams): Promise> => { - const response = await api.get>('/v1/gacha/items/', { params }); + const response = await api.get>('/v1/gacha/items', { params }); return response.data; };