From 4362db39460ada67d2a23d7b28d8f1eefb8a576d Mon Sep 17 00:00:00 2001 From: devzeeh <148837352+devzeeh@users.noreply.github.com> Date: Wed, 29 Apr 2026 14:31:38 +0800 Subject: [PATCH 1/3] feat: implement signup flow with OTP verification, card validation, and backend API handlers --- backend/cmd/app/main.go | 3 + backend/internal/auth/signup_verification.go | 136 ++++++++ backend/internal/pkg/handler/jsonWrite.go | 1 + frontend/assets/js/signup.js | 349 +++++++++++-------- frontend/assets/style/index.css | 4 - frontend/assets/style/style.css | 19 + frontend/templates/signup.html | 175 +++++++--- 7 files changed, 478 insertions(+), 209 deletions(-) create mode 100644 backend/internal/auth/signup_verification.go delete mode 100644 frontend/assets/style/index.css create mode 100644 frontend/assets/style/style.css diff --git a/backend/cmd/app/main.go b/backend/cmd/app/main.go index 7ed6673..8d3d454 100644 --- a/backend/cmd/app/main.go +++ b/backend/cmd/app/main.go @@ -71,6 +71,9 @@ func main() { // POST Request: JSON API endpoints mux.HandleFunc("POST /v1/loginauth", authHandler.LoginAuthHandler) // Login authentication endpoint mux.HandleFunc("POST /v1/signupauth", authHandler.SignupHandler) + mux.HandleFunc("POST /v1/signup/check-details", authHandler.CheckDetailsHandler) + mux.HandleFunc("POST /v1/signup/verify-otp", authHandler.VerifyOTPHandler) + mux.HandleFunc("POST /v1/signup/check-card", authHandler.CheckCardHandler) mux.HandleFunc("GET /login", authHandler.LoginView) mux.HandleFunc("GET /signup", authHandler.SignupView) mux.HandleFunc("GET /dashboard", userHandler.DashboardHandler) diff --git a/backend/internal/auth/signup_verification.go b/backend/internal/auth/signup_verification.go new file mode 100644 index 0000000..8c8de97 --- /dev/null +++ b/backend/internal/auth/signup_verification.go @@ -0,0 +1,136 @@ +package authentication + +import ( + "encoding/json" + "fmt" + "log" + "math/rand" + "net/http" + "sync" + "time" + "unicard-go/backend/internal/pkg/account" + jsonwrite "unicard-go/backend/internal/pkg/handler" +) + +// In-memory store for OTPs (In production, use Redis or DB) +var ( + otpStore = make(map[string]string) + otpMutex sync.RWMutex +) + +type CheckDetailsRequest struct { + Email string `json:"email"` + ContactNumber string `json:"contact_number"` +} + +type VerifyOTPRequest struct { + Email string `json:"email"` + OTP string `json:"otp"` +} + +type CheckCardRequest struct { + CardNumber string `json:"card_number"` +} + +// CheckDetailsHandler checks if email and phone are available and sends OTP +func (h *Handler) CheckDetailsHandler(w http.ResponseWriter, r *http.Request) { + var req CheckDetailsRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{Success: false, Message: "Invalid request"}) + return + } + + // Check Email + exists, err := account.IsEmailExist(h.DB, req.Email) + if err != nil { + jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{Success: false, Message: "Database error"}) + return + } + if exists { + jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{Success: false, Message: "Email already registered", Field: "email"}) + return + } + + // Check Phone + exists, err = h.isPhoneExist(req.ContactNumber) + if err != nil { + jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{Success: false, Message: "Database error"}) + return + } + if exists { + jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{Success: false, Message: "Phone number already registered", Field: "phone"}) + return + } + + // Generate OTP + otp := fmt.Sprintf("%06d", rand.Intn(1000000)) + otpMutex.Lock() + otpStore[req.Email] = otp + otpMutex.Unlock() + + // Log OTP for development (since we don't have SMTP configured here) + log.Printf("SIGNUP OTP for %s: %s", req.Email, otp) + + jsonwrite.WriteJSON(w, http.StatusOK, jsonwrite.APIResponse{ + Success: true, + Message: "OTP sent successfully", + }) +} + +// VerifyOTPHandler checks the entered OTP +func (h *Handler) VerifyOTPHandler(w http.ResponseWriter, r *http.Request) { + var req VerifyOTPRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{Success: false, Message: "Invalid request"}) + return + } + + otpMutex.RLock() + storedOTP, ok := otpStore[req.Email] + otpMutex.RUnlock() + + if !ok || storedOTP != req.OTP { + jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{Success: false, Message: "Invalid or expired OTP"}) + return + } + + // Optional: Clear OTP after successful verification + // otpMutex.Lock() + // delete(otpStore[req.Email]) + // otpMutex.Unlock() + + jsonwrite.WriteJSON(w, http.StatusOK, jsonwrite.APIResponse{ + Success: true, + Message: "OTP verified successfully", + }) +} + +// CheckCardHandler checks if card is valid for registration +func (h *Handler) CheckCardHandler(w http.ResponseWriter, r *http.Request) { + var req CheckCardRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{Success: false, Message: "Invalid request"}) + return + } + + var status string + err := h.DB.QueryRow("SELECT status FROM cards WHERE card_number = ?", req.CardNumber).Scan(&status) + if err != nil { + jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{Success: false, Message: "Card not found"}) + return + } + + if status != "Inactive" { + jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{Success: false, Message: "Card is already active or restricted"}) + return + } + + jsonwrite.WriteJSON(w, http.StatusOK, jsonwrite.APIResponse{ + Success: true, + Message: "Card is valid", + }) +} + +func init() { + rand.Seed(time.Now().UnixNano()) +} diff --git a/backend/internal/pkg/handler/jsonWrite.go b/backend/internal/pkg/handler/jsonWrite.go index 46ce8b9..17caeb1 100644 --- a/backend/internal/pkg/handler/jsonWrite.go +++ b/backend/internal/pkg/handler/jsonWrite.go @@ -9,6 +9,7 @@ import ( type APIResponse struct { Success bool `json:"success"` Message string `json:"message"` + Field string `json:"field,omitempty"` } // Login specific response — returns user data after login diff --git a/frontend/assets/js/signup.js b/frontend/assets/js/signup.js index 1019e2c..d970ba6 100644 --- a/frontend/assets/js/signup.js +++ b/frontend/assets/js/signup.js @@ -1,41 +1,61 @@ -// Global helper functions for input restriction -function isValidEmail(email) { - const regex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/; - return regex.test(email); -} - function isNumber(evt) { evt = (evt) ? evt : window.event; var charCode = (evt.which) ? evt.which : evt.keyCode; - // Allow only numbers - if (charCode > 31 && (charCode < 48 || charCode > 57)) { - return false; - } + if (charCode > 31 && (charCode < 48 || charCode > 57)) return false; return true; } function isAlpha(evt) { evt = (evt) ? evt : window.event; var charCode = (evt.which) ? evt.which : evt.keyCode; - // Allow letters and spaces. Also allow control characters < 32. if (charCode < 32) return true; if ((charCode >= 65 && charCode <= 90) || (charCode >= 97 && charCode <= 122) || - charCode === 32) { + charCode === 32) return true; + return false; +} + +function validateInput(el) { + const errorEl = document.getElementById(`error-${el.id}`); + if (!el.checkValidity() || el.value.trim() === "") { + el.classList.add('border-red-500', 'focus:border-red-500', 'focus:ring-red-500'); + el.classList.remove('border-gray-300', 'focus:border-blue-500', 'focus:ring-blue-500'); + if (errorEl) { + if (el.value.trim() === "") { + errorEl.textContent = "This field is required."; + } else if (el.type === "email") { + errorEl.textContent = "Please enter a valid email address."; + } else if (el.id === "contact_number") { + errorEl.textContent = "Please enter a valid 11-digit contact number."; + } else if (el.id === "card_id") { + errorEl.textContent = "Please enter a valid 16-digit card number."; + } else { + errorEl.textContent = "Invalid format."; + } + errorEl.classList.remove('hidden'); + } + return false; + } else { + el.classList.remove('border-red-500', 'focus:border-red-500', 'focus:ring-red-500'); + el.classList.add('border-gray-300', 'focus:border-blue-500', 'focus:ring-blue-500'); + if (errorEl) errorEl.classList.add('hidden'); return true; } - return false; } document.addEventListener("DOMContentLoaded", function () { // STEP ELEMENTS const step1 = document.getElementById('step-1'); + const stepOTP = document.getElementById('step-otp'); const step2 = document.getElementById('step-2'); const step3 = document.getElementById('step-3'); const stepSubtitle = document.getElementById('step-subtitle'); + const stepProgress = document.getElementById('step-progress'); // BUTTONS const btnStep1 = document.getElementById('btn-step-1'); + const btnBackOTP = document.getElementById('btn-back-otp'); + const btnStepOTP = document.getElementById('btn-step-otp'); const btnBack2 = document.getElementById('btn-back-2'); const btnStep2 = document.getElementById('btn-step-2'); const btnBack3 = document.getElementById('btn-back-3'); @@ -47,58 +67,53 @@ document.addEventListener("DOMContentLoaded", function () { const lastNameInput = document.getElementById('last_name'); const emailInput = document.getElementById('email'); const contactNumberInput = document.getElementById('contact_number'); - - // INPUTS + const otpInput = document.getElementById('otp'); const cardIdInput = document.getElementById('card_id'); - const cardIdError = document.getElementById('card-id-error'); - - // INPUTS const passwordInput = document.getElementById('password'); const confirmPasswordInput = document.getElementById('confirm_password'); + + // FEEDBACK + const cardIdError = document.getElementById('card-id-error'); + const errorMessage = document.getElementById('error-message'); const checklist = document.getElementById('validation-checklist'); const lengthCheck = document.getElementById('length-check'); const matchCheck = document.getElementById('match-check'); - // MODAL (ADDED) + // MODAL const successModal = document.getElementById('success-modal'); const modalCloseBtn = document.getElementById('modal-close-btn'); - // GLOBAL - const errorMessage = document.getElementById('error-message'); - // FORM DATA STORAGE const formData = { firstName: '', lastName: '', email: '', + contactNumber: '', cardId: '', password: '', }; - // ROBUSTNESS CHECK - if (!step1 || !step2 || !step3 || !stepSubtitle || !btnStep1 || !btnBack2 || !btnStep2 || !btnBack3 || !createAccountBtn || !signupForm || !firstNameInput || !emailInput || !cardIdInput || !cardIdError || !passwordInput || !confirmPasswordInput || !checklist || !lengthCheck || !matchCheck || !errorMessage || !successModal || !modalCloseBtn) { - console.error("Signup Script Error: Not all required HTML elements were found on the page."); - return; // Stop the script - } - - // INITIALIZATION - // HELPER FUNCTIONS function showStep(stepNumber) { - step1.classList.add('hidden'); - step2.classList.add('hidden'); - step3.classList.add('hidden'); + [step1, stepOTP, step2, step3].forEach(s => s?.classList.add('hidden')); errorMessage.classList.add('hidden'); if (stepNumber === 1) { step1.classList.remove('hidden'); - stepSubtitle.textContent = 'Step 1 of 3: Your Details'; + stepSubtitle.textContent = 'Step 1 of 4: Your Details'; + if (stepProgress) stepProgress.style.width = '25%'; + } else if (stepNumber === 'otp') { + stepOTP.classList.remove('hidden'); + stepSubtitle.textContent = 'Step 2 of 4: Verify Email'; + if (stepProgress) stepProgress.style.width = '50%'; } else if (stepNumber === 2) { step2.classList.remove('hidden'); - stepSubtitle.textContent = 'Step 2 of 3: Card Verification'; + stepSubtitle.textContent = 'Step 3 of 4: Card Verification'; + if (stepProgress) stepProgress.style.width = '75%'; } else if (stepNumber === 3) { step3.classList.remove('hidden'); - stepSubtitle.textContent = 'Step 3 of 3: Create Password'; + stepSubtitle.textContent = 'Step 4 of 4: Create Password'; + if (stepProgress) stepProgress.style.width = '100%'; } } @@ -118,78 +133,128 @@ document.addEventListener("DOMContentLoaded", function () { function showError(message) { errorMessage.textContent = message; errorMessage.classList.remove('hidden'); + errorMessage.scrollIntoView({ behavior: 'smooth', block: 'center' }); } - // VALIDATION LOGIC - - // Real-time validation for Name and Email fields - function validateStep1Realtime() { - const firstName = firstNameInput.value.trim(); - const lastName = lastNameInput.value.trim(); - const email = emailInput.value.trim(); - const contactNumber = contactNumberInput.value.trim(); - - const isNameValid = firstName !== '' && lastName !== ''; - const isEmailValid = email !== '' && isValidEmail(email); - const isContactValid = contactNumber !== ''; - - if (isNameValid && isEmailValid && isContactValid) { - errorMessage.classList.add('hidden'); - } - } + // VALIDATION & API CALLS + + async function validateStep1() { + const inputs = [firstNameInput, lastNameInput, emailInput, contactNumberInput]; + let isValid = true; + inputs.forEach(input => { + if (!validateInput(input)) isValid = false; + }); + + if (!isValid) return false; + + // Backend check for Email/Phone availability + try { + btnStep1.disabled = true; + btnStep1.textContent = 'Checking...'; + + const response = await fetch('/v1/signup/check-details', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + email: emailInput.value.trim(), + contact_number: contactNumberInput.value.trim() + }) + }); + + const data = await response.json(); + if (!data.success) { + showError(data.message); + if (data.field === 'email') { + emailInput.classList.add('border-red-500', 'focus:border-red-500', 'focus:ring-red-500'); + emailInput.classList.remove('border-gray-300', 'focus:border-blue-500', 'focus:ring-blue-500'); + } + if (data.field === 'phone') { + contactNumberInput.classList.add('border-red-500', 'focus:border-red-500', 'focus:ring-red-500'); + contactNumberInput.classList.remove('border-gray-300', 'focus:border-blue-500', 'focus:ring-blue-500'); + } + return false; + } - // Click validation (shows error) - function validateStep1() { - const firstName = firstNameInput.value.trim(); - const lastName = lastNameInput.value.trim(); - const email = emailInput.value.trim(); - const contactNumber = contactNumberInput.value.trim(); - - if (firstName === '' || lastName === '' || email === '' || !isValidEmail(email) || contactNumber === '') { - showError('Please fill all fields and provide a valid email address.'); + formData.firstName = firstNameInput.value.trim(); + formData.lastName = lastNameInput.value.trim(); + formData.email = emailInput.value.trim(); + formData.contactNumber = contactNumberInput.value.trim(); + return true; + } catch (err) { + showError('Network error. Please try again.'); return false; + } finally { + btnStep1.disabled = false; + btnStep1.textContent = 'Next'; } - - // TODO: Backend check for email - formData.firstName = firstName; - formData.lastName = lastName; - formData.email = email; - formData.contactNumber = contactNumber; - return true; } - // Validate Card ID (real-time and click) - function validateStep2() { - const cardId = cardIdInput.value.trim(); + async function validateOTP() { + if (!validateInput(otpInput)) return false; - if (cardId === "") { - cardIdError.textContent = 'Please enter your Card ID.'; - cardIdError.classList.remove('hidden'); + try { + btnStepOTP.disabled = true; + btnStepOTP.textContent = 'Verifying...'; + + const response = await fetch('/v1/signup/verify-otp', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + email: formData.email, + otp: otpInput.value.trim() + }) + }); + + const data = await response.json(); + if (!data.success) { + showError(data.message); + otpInput.classList.add('border-red-500', 'focus:border-red-500', 'focus:ring-red-500'); + otpInput.classList.remove('border-gray-300', 'focus:border-blue-500', 'focus:ring-blue-500'); + return false; + } + return true; + } catch (err) { + showError('Network error. Please try again.'); return false; + } finally { + btnStepOTP.disabled = false; + btnStepOTP.textContent = 'Verify'; } + } - const isCardIdOnlyNumbers = /^\d+$/.test(cardId); - const isCardIdValidLength = cardId.length === 16; - - if (!isCardIdOnlyNumbers) { - cardIdError.textContent = 'Card ID must contain only numbers.'; - cardIdError.classList.remove('hidden'); - return false; - } else if (!isCardIdValidLength) { - cardIdError.textContent = 'Card ID must be exactly 10 digits long.'; - cardIdError.classList.remove('hidden'); + async function validateStep2() { + if (!validateInput(cardIdInput)) return false; + + try { + btnStep2.disabled = true; + btnStep2.textContent = 'Verifying...'; + + const response = await fetch('/v1/signup/check-card', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ card_number: cardIdInput.value.trim() }) + }); + + const data = await response.json(); + if (!data.success) { + showError(data.message); + cardIdInput.classList.add('border-red-500', 'focus:border-red-500', 'focus:ring-red-500'); + cardIdInput.classList.remove('border-gray-300', 'focus:border-blue-500', 'focus:ring-blue-500'); + return false; + } + + formData.cardId = cardIdInput.value.trim(); + return true; + } catch (err) { + showError('Network error. Please try again.'); return false; + } finally { + btnStep2.disabled = false; + btnStep2.textContent = 'Next'; } - - // TODO: Backend check for card ID - - cardIdError.classList.add('hidden'); - formData.cardId = cardId; - return true; } - // Validate Password - function validateStep3() { + function updatePasswordChecklist() { const password = passwordInput.value; const confirmPassword = confirmPasswordInput.value; @@ -198,10 +263,21 @@ document.addEventListener("DOMContentLoaded", function () { updateChecklistItem(lengthCheck, isLengthValid); updateChecklistItem(matchCheck, passwordsMatch); + } + + function validateStep3() { + const p1 = validateInput(passwordInput); + const p2 = validateInput(confirmPasswordInput); - const allValid = isLengthValid && passwordsMatch; + updatePasswordChecklist(); - if (allValid) { + const password = passwordInput.value; + const confirmPassword = confirmPasswordInput.value; + + const isLengthValid = password.length >= 8; + const passwordsMatch = password === confirmPassword && password.length > 0; + + if (p1 && p2 && isLengthValid && passwordsMatch) { formData.password = password; return true; } @@ -209,49 +285,34 @@ document.addEventListener("DOMContentLoaded", function () { } // EVENT LISTENERS - - // Add real-time listeners for Step 1 - firstNameInput.addEventListener('input', validateStep1Realtime); - lastNameInput.addEventListener('input', validateStep1Realtime); - contactNumberInput.addEventListener('input', validateStep1Realtime); - - // Next from Step 1 - btnStep1.addEventListener('click', function () { - if (validateStep1()) { - showStep(2); - } + btnStep1.addEventListener('click', async () => { + if (await validateStep1()) showStep('otp'); }); - // Back from Step 2 - btnBack2.addEventListener('click', function () { - showStep(1); + btnBackOTP.addEventListener('click', () => showStep(1)); + btnStepOTP.addEventListener('click', async () => { + if (await validateOTP()) showStep(2); }); - // Next from Step 2 - btnStep2.addEventListener('click', function () { - if (validateStep2()) { - showStep(3); - } + btnBack2.addEventListener('click', () => showStep('otp')); + btnStep2.addEventListener('click', async () => { + if (await validateStep2()) showStep(3); }); - - // Real-time validation for Card ID as user types - cardIdInput.addEventListener('input', validateStep2); - // Back from Step 3 - btnBack3.addEventListener('click', function () { - showStep(2); - }); + btnBack3.addEventListener('click', () => showStep(2)); - // Real-time validation for Password fields - passwordInput.addEventListener('input', validateStep3); - confirmPasswordInput.addEventListener('input', validateStep3); + passwordInput.addEventListener('input', updatePasswordChecklist); + confirmPasswordInput.addEventListener('input', updatePasswordChecklist); - // Final Form Submission - signupForm.addEventListener('submit', function (event) { + signupForm.addEventListener('submit', async (event) => { event.preventDefault(); + if (!validateStep3()) return; - if (validateStep3()) { - fetch("/v1/signupauth", { + try { + createAccountBtn.disabled = true; + createAccountBtn.textContent = 'Creating...'; + + const response = await fetch("/v1/signupauth", { method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify({ @@ -262,27 +323,21 @@ document.addEventListener("DOMContentLoaded", function () { card_number: formData.cardId, password: formData.password }) - }) - .then(res => res.json()) - .then(data => { - if (data.success) { - successModal.classList.remove('hidden'); - } else { - showError(data.message || 'Failed to create account'); - } - }) - .catch(err => { - console.error(err); - showError('Network error occurred.'); }); - } else { - showError('Please correct the errors in the password fields.'); + + const data = await response.json(); + if (data.success) { + successModal.classList.remove('hidden'); + } else { + showError(data.message || 'Failed to create account'); + } + } catch (err) { + showError('Network error occurred.'); + } finally { + createAccountBtn.disabled = false; + createAccountBtn.textContent = 'Create Account'; } }); - // MODAL BUTTON (ADDED) - // Add event listener for the modal's "Go to Login" button - modalCloseBtn.addEventListener('click', function() { - window.location.href = "/login"; - }); + modalCloseBtn.addEventListener('click', () => window.location.href = "/login"); }); \ No newline at end of file diff --git a/frontend/assets/style/index.css b/frontend/assets/style/index.css deleted file mode 100644 index 40fa6e9..0000000 --- a/frontend/assets/style/index.css +++ /dev/null @@ -1,4 +0,0 @@ -.required { - color: red; - margin-left: 3px; -} \ No newline at end of file diff --git a/frontend/assets/style/style.css b/frontend/assets/style/style.css new file mode 100644 index 0000000..c1ed756 --- /dev/null +++ b/frontend/assets/style/style.css @@ -0,0 +1,19 @@ +.required label::after { + content: " *"; + color: #ef4444; +} + +input.error { + border-color: #ef4444 !important; + outline: 2px solid transparent !important; + outline-offset: 2px !important; + --tw-ring-color: #ef4444 !important; + ring-width: 2px !important; +} + +.error-text { + color: #ef4444; + font-size: 0.75rem; + margin-top: 0.25rem; + display: block; +} \ No newline at end of file diff --git a/frontend/templates/signup.html b/frontend/templates/signup.html index 473abac..ea7250e 100644 --- a/frontend/templates/signup.html +++ b/frontend/templates/signup.html @@ -10,48 +10,57 @@ - + - +

Create your account

-

Step 1 of 3: Your Details

+

Step 1 of 4: Your Details

+
+
+
-
- -
- - - - +
+
+ +
+ + + + +
+
- -
- - - - +
+ +
+ + + + +
+
@@ -65,8 +74,10 @@

Create your account

+
+
+
+
-