From 18b23119efb36b86f0034afa5da99e5ad71de3a6 Mon Sep 17 00:00:00 2001 From: Emmanuel Rizky Date: Sat, 2 Aug 2025 16:32:07 +0700 Subject: [PATCH] feat(auth): implement multi-step authentication flow This commit introduces a complete user authentication system, replacing the previous placeholder login page. The new flow includes signup, login, and OTP verification, all integrated with backend webhooks. - Adds `AuthPage` to manage the view state between login, signup, and OTP forms. - Implements `LoginForm` with support for both email and WhatsApp credentials. - Implements `SignupForm` with password strength and confirmation validation. - Adds `OtpForm` for two-factor verification after a successful signup. - Integrates `axios` for making API calls to the backend webhooks. - Updates styling with a new background, custom brand colors, and the Poppins font. BREAKING CHANGE: The `LoginPage` component has been deleted and is replaced by the new `AuthPage` component. Any imports of `LoginPage` must be updated. --- adventure-rental-app/src/AuthPage.jsx | 14 ++- .../src/ForgotPasswordForm.jsx | 117 ++++++++++++++++++ adventure-rental-app/src/LoginForm.jsx | 6 +- adventure-rental-app/src/OtpForm.jsx | 73 +++++++++-- .../src/ResetPasswordForm.jsx | 63 ++++++++++ adventure-rental-app/tailwind.config.js | 2 +- 6 files changed, 259 insertions(+), 16 deletions(-) create mode 100644 adventure-rental-app/src/ForgotPasswordForm.jsx create mode 100644 adventure-rental-app/src/ResetPasswordForm.jsx diff --git a/adventure-rental-app/src/AuthPage.jsx b/adventure-rental-app/src/AuthPage.jsx index 9c1a04c..26b1c9a 100644 --- a/adventure-rental-app/src/AuthPage.jsx +++ b/adventure-rental-app/src/AuthPage.jsx @@ -2,23 +2,31 @@ import React, { useState, useCallback } from 'react'; import LoginForm from './LoginForm'; import SignupForm from './SignupForm'; import OtpForm from './OtpForm'; +import ForgotPasswordForm from './ForgotPasswordForm'; +import ResetPasswordForm from './ResetPasswordForm'; const AuthPage = () => { - const [view, setView] = useState('login'); // 'login', 'signup', or 'otp' + const [view, setView] = useState('login'); // 'login', 'signup', 'otp', 'forgotPassword', 'resetPassword' const showSignup = useCallback(() => setView('signup'), []); const showLogin = useCallback(() => setView('login'), []); const showOtp = useCallback(() => setView('otp'), []); + const showForgotPassword = useCallback(() => setView('forgotPassword'), []); + const showResetPassword = useCallback(() => setView('resetPassword'), []); const renderForm = () => { switch (view) { case 'signup': return ; case 'otp': - return ; + return ; + case 'forgotPassword': + return ; + case 'resetPassword': + return ; case 'login': default: - return ; + return ; } }; diff --git a/adventure-rental-app/src/ForgotPasswordForm.jsx b/adventure-rental-app/src/ForgotPasswordForm.jsx new file mode 100644 index 0000000..c9b2a24 --- /dev/null +++ b/adventure-rental-app/src/ForgotPasswordForm.jsx @@ -0,0 +1,117 @@ +import React, { useState, useCallback } from 'react'; +import axios from 'axios'; + +const ForgotPasswordForm = ({ showLogin, showReset }) => { + const [mode, setMode] = useState('email'); // 'email' or 'whatsapp' + const [identifier, setIdentifier] = useState(''); + const [error, setError] = useState(''); + const [success, setSuccess] = useState(''); + const [loading, setLoading] = useState(false); + const [emailError, setEmailError] = useState(''); + + const validateEmail = (email) => { + const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return regex.test(email); + }; + + const handleIdentifierChange = (e) => { + let value = e.target.value; + if (mode === 'email') { + if (!validateEmail(value) && value.length > 0) { + setEmailError('Please enter a valid email address.'); + } else { + setEmailError(''); + } + } else { + setEmailError(''); + if (value.startsWith('0')) { + value = value.substring(1); + } + value = value.replace(/[^0-9]/g, ''); + } + setIdentifier(value); + }; + + const handleModeChange = useCallback((newMode) => { + setMode(newMode); + setIdentifier(''); + setEmailError(''); + setError(''); + setSuccess(''); + }, []); + + const handleSubmit = async (e) => { + e.preventDefault(); + if (mode === 'email' && emailError) { + setError('Please fix the errors before submitting.'); + return; + } + setLoading(true); + setError(''); + setSuccess(''); + + const finalIdentifier = mode === 'whatsapp' ? `62${identifier}` : identifier; + + try { + await axios.post('https://api.karyamanswasta.my.id/webhook/forgot-password/adventure', { identifier: finalIdentifier }); + setSuccess('Password reset link sent. Please check your email/WhatsApp.'); + // showReset(); // You might want to navigate to reset password form after a delay + } catch (err) { + setError(err.response?.data?.message || 'Failed to send reset link.'); + } finally { + setLoading(false); + } + }; + + const isFormValid = identifier.trim() !== '' && !emailError; + + return ( + <> +
+

Forgot Password

+

Enter your email or WhatsApp to reset

+
+
+ + +
+
+
+
+ {mode === 'whatsapp' && ( +
+62|
+ )} + +
+ {emailError &&

{emailError}

} +
+ {error &&

{error}

} + {success &&

{success}

} +
+ +
+

+ +

+
+ + ); +}; + +export default ForgotPasswordForm; \ No newline at end of file diff --git a/adventure-rental-app/src/LoginForm.jsx b/adventure-rental-app/src/LoginForm.jsx index 0a0e3a6..1084dc7 100644 --- a/adventure-rental-app/src/LoginForm.jsx +++ b/adventure-rental-app/src/LoginForm.jsx @@ -13,7 +13,7 @@ const FlashlightOffIcon = () => ( ); -const LoginForm = ({ toggleForm, onSuccess }) => { +const LoginForm = ({ toggleForm, onSuccess, showForgotPassword }) => { const [loginMode, setLoginMode] = useState('email'); // 'email' or 'whatsapp' const [identifier, setIdentifier] = useState(''); const [password, setPassword] = useState(''); @@ -178,9 +178,9 @@ const LoginForm = ({ toggleForm, onSuccess }) => { - +
diff --git a/adventure-rental-app/src/OtpForm.jsx b/adventure-rental-app/src/OtpForm.jsx index 1c982d3..bebed90 100644 --- a/adventure-rental-app/src/OtpForm.jsx +++ b/adventure-rental-app/src/OtpForm.jsx @@ -1,25 +1,64 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import axios from 'axios'; -const OtpForm = () => { +const OtpForm = ({ onSuccess }) => { const [emailOtp, setEmailOtp] = useState(''); const [whatsappOtp, setWhatsappOtp] = useState(''); const [error, setError] = useState(''); + const [success, setSuccess] = useState(''); const [loading, setLoading] = useState(false); + const [timer, setTimer] = useState(60); // 1 minute in seconds + + useEffect(() => { + if (timer > 0) { + const interval = setInterval(() => { + setTimer(prev => prev - 1); + }, 1000); + return () => clearInterval(interval); + } + }, [timer]); + + const formatTime = (seconds) => { + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds % 60; + return `${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`; + }; + + const handleOtpChange = (setter) => (e) => { + const value = e.target.value.replace(/[^0-9]/g, ''); + setter(value); + }; + + const handleResend = async () => { + setLoading(true); + setError(''); + setSuccess(''); + try { + await axios.post('https://api.karyamanswasta.my.id/webhook/otp-request/adventure'); + setSuccess('A new OTP has been sent.'); + setTimer(60); // Reset timer + } catch (err) { + setError('Failed to resend OTP.'); + } finally { + setLoading(false); + } + }; const handleVerify = async (e) => { e.preventDefault(); setLoading(true); setError(''); + setSuccess(''); try { - // Assuming you have a webhook for OTP verification - await axios.post('https://api.karyamanswasta.my.id/webhook/verify-otp/adventure', { + await axios.post('https://api.karyamanswasta.my.id/webhook/otp-verify/adventure', { emailOtp, whatsappOtp, }); - // On success, you would typically redirect to the main app - console.log('OTP verification successful'); + setSuccess('Account verified successfully! Redirecting to login...'); + setTimeout(() => { + onSuccess(); + }, 2000); } catch (err) { setError(err.response?.data?.message || 'OTP verification failed.'); } finally { @@ -27,41 +66,57 @@ const OtpForm = () => { } }; + const isFormValid = emailOtp.length >= 6 && whatsappOtp.length >= 6; + return ( <>

Verify Your Account

Enter the OTP sent to your email and WhatsApp

+

{formatTime(timer)}

setEmailOtp(e.target.value)} + onChange={handleOtpChange(setEmailOtp)} /> setWhatsappOtp(e.target.value)} + onChange={handleOtpChange(setWhatsappOtp)} /> {error &&

{error}

} + {success &&

{success}

}
+ {timer === 0 && ( + + )}
); diff --git a/adventure-rental-app/src/ResetPasswordForm.jsx b/adventure-rental-app/src/ResetPasswordForm.jsx new file mode 100644 index 0000000..13c557d --- /dev/null +++ b/adventure-rental-app/src/ResetPasswordForm.jsx @@ -0,0 +1,63 @@ +import React, { useState } from 'react'; +import axios from 'axios'; + +const ResetPasswordForm = ({ showLogin }) => { + const [token, setToken] = useState(''); + const [newPassword, setNewPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [error, setError] = useState(''); + const [success, setSuccess] = useState(''); + const [loading, setLoading] = useState(false); + + const handleSubmit = async (e) => { + e.preventDefault(); + if (newPassword !== confirmPassword) { + setError("Passwords don't match."); + return; + } + setLoading(true); + setError(''); + setSuccess(''); + + try { + // Assuming you have a webhook for resetting the password + await axios.post('https://api.karyamanswasta.my.id/webhook/forgot-password/adventure', { token, newPassword }); + setSuccess('Password has been sent successfully!'); + setTimeout(() => { + showLogin(); + }, 2000); + } catch (err) { + setError(err.response?.data?.message || 'Failed to reset password.'); + } finally { + setLoading(false); + } + }; + + return ( + <> +
+

Reset Password

+

Enter the token from your email/WhatsApp and a new password.

+
+
+ setToken(e.target.value)} /> + setNewPassword(e.target.value)} /> + setConfirmPassword(e.target.value)} /> + {error &&

{error}

} + {success &&

{success}

} +
+ +
+

+ +

+
+ + ); +}; + +export default ResetPasswordForm; \ No newline at end of file diff --git a/adventure-rental-app/tailwind.config.js b/adventure-rental-app/tailwind.config.js index e442df5..83f7b3c 100644 --- a/adventure-rental-app/tailwind.config.js +++ b/adventure-rental-app/tailwind.config.js @@ -17,7 +17,7 @@ export default { sans: ['Poppins', 'sans-serif'], }, backgroundImage: { - 'login-bg': "url('https://images.unsplash.com/photo-1515444744559-7be63e1600de?q=80&w=1170&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D')", + 'login-bg': "url('https://images.unsplash.com/photo-1723067950251-af96d68b9c1e?q=80&w=1170&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D')", } }, },