diff --git a/backend/src/users/dto/update-profile.dto.ts b/backend/src/users/dto/update-profile.dto.ts index 2f3b88c..aa133e6 100644 --- a/backend/src/users/dto/update-profile.dto.ts +++ b/backend/src/users/dto/update-profile.dto.ts @@ -1,5 +1,5 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; -import { IsOptional, IsString, IsEmail } from 'class-validator'; +import { IsOptional, IsString, IsEmail, MinLength } from 'class-validator'; export class UpdateProfileDto { @ApiPropertyOptional({ description: 'نام کاربر' }) @@ -21,4 +21,15 @@ export class UpdateProfileDto { @IsOptional() @IsString() mobile?: string; + + @ApiPropertyOptional({ description: 'رمز عبور جدید' }) + @IsOptional() + @IsString() + @MinLength(6, { message: 'رمز عبور باید حداقل ۶ کاراکتر باشد' }) + password?: string; + + @ApiPropertyOptional({ description: 'رمز عبور فعلی (در صورت وجود)' }) + @IsOptional() + @IsString() + currentPassword?: string; } diff --git a/backend/src/users/users.service.ts b/backend/src/users/users.service.ts index 92196aa..9b61e94 100644 --- a/backend/src/users/users.service.ts +++ b/backend/src/users/users.service.ts @@ -1,6 +1,13 @@ -import { Injectable } from '@nestjs/common'; +import { + Injectable, + BadRequestException, + NotFoundException, +} from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; +import * as bcrypt from 'bcryptjs'; +import { normalizeMobile } from '../common/utils/phone.utils'; +import { UpdateProfileDto } from './dto/update-profile.dto'; export interface UserAddressInput { title: string; @@ -49,7 +56,13 @@ export class UsersService { }, }); - if (dbUser) return dbUser; + if (dbUser) { + const { password, ...safeUser } = dbUser; + return { + ...safeUser, + hasPassword: !!password, + }; + } return { id: '12345678-1234-1234-1234-123456789012', @@ -64,12 +77,13 @@ export class UsersService { orders: [], addresses: [], walletTransactions: [], + hasPassword: true, createdAt: new Date(), updatedAt: new Date(), }; } - return this.prisma.user.findUnique({ + const user = await this.prisma.user.findUnique({ where: { id }, include: { pets: { @@ -98,12 +112,70 @@ export class UsersService { }, }, }); + + if (!user) return null; + + const { password, ...safeUser } = user; + return { + ...safeUser, + hasPassword: !!password, + }; } - async update(id: string, data: Prisma.UserUpdateInput) { - return this.prisma.user.update({ + async update(id: string, dto: UpdateProfileDto) { + const user = await this.prisma.user.findUnique({ where: { id } }); + if (!user) { + throw new NotFoundException('کاربر یافت نشد'); + } + + const updateData: Prisma.UserUpdateInput = {}; + + if (dto.firstName !== undefined) updateData.firstName = dto.firstName.trim(); + if (dto.lastName !== undefined) updateData.lastName = dto.lastName.trim(); + + if (dto.mobile !== undefined) { + const normalizedMobile = normalizeMobile(dto.mobile); + if (normalizedMobile && normalizedMobile !== user.mobile) { + const existing = await this.prisma.user.findUnique({ + where: { mobile: normalizedMobile }, + }); + if (existing && existing.id !== id) { + throw new BadRequestException('این شماره موبایل قبلاً توسط کاربر دیگری ثبت شده است'); + } + updateData.mobile = normalizedMobile; + } + } + + if (dto.email !== undefined) { + const normalizedEmail = dto.email ? dto.email.trim().toLowerCase() : null; + if (normalizedEmail && normalizedEmail !== user.email) { + const existing = await this.prisma.user.findUnique({ + where: { email: normalizedEmail }, + }); + if (existing && existing.id !== id) { + throw new BadRequestException('این آدرس ایمیل قبلاً توسط کاربر دیگری ثبت شده است'); + } + } + updateData.email = normalizedEmail; + } + + if (dto.password && dto.password.trim().length >= 6) { + // If user already has a password set, require and verify currentPassword + if (user.password) { + if (!dto.currentPassword) { + throw new BadRequestException('برای تغییر رمز عبور، وارد کردن رمز عبور فعلی الزامی است'); + } + const isCurrentMatch = await bcrypt.compare(dto.currentPassword.trim(), user.password); + if (!isCurrentMatch) { + throw new BadRequestException('رمز عبور فعلی اشتباه است'); + } + } + updateData.password = await bcrypt.hash(dto.password.trim(), 10); + } + + const updatedUser = await this.prisma.user.update({ where: { id }, - data, + data: updateData, include: { pets: true, orders: { @@ -117,6 +189,12 @@ export class UsersService { walletTransactions: true, }, }); + + const { password, ...safeUser } = updatedUser; + return { + ...safeUser, + hasPassword: !!password, + }; } async addAddress(userId: string, data: UserAddressInput) { diff --git a/frontend/application/components/AuthModal.tsx b/frontend/application/components/AuthModal.tsx index 3972583..1b23f61 100644 --- a/frontend/application/components/AuthModal.tsx +++ b/frontend/application/components/AuthModal.tsx @@ -1,11 +1,25 @@ "use client"; -import React, { useState, useEffect, useRef } from "react"; +import React, { useState, useEffect, useRef, useCallback } from "react"; import { motion, AnimatePresence } from "motion/react"; -import { X, Building2, ShieldCheck, Phone, Key, LogIn, ArrowRight, RefreshCw, Lock, Upload, CheckCircle2 } from "lucide-react"; +import { + X, + Building2, + ShieldCheck, + Phone, + Key, + LogIn, + ArrowRight, + RefreshCw, + Lock, + Upload, + CheckCircle2, + Eye, + EyeOff, +} from "lucide-react"; import { useUserStore } from "../lib/store/userStore"; import { authService } from "../lib/services/authService"; import { toast } from "sonner"; -import { toPersian } from "../lib/utils"; +import { toPersian, toEnglishDigits } from "../lib/utils"; interface AuthModalProps { isOpen: boolean; @@ -14,7 +28,16 @@ interface AuthModalProps { export default function AuthModal({ isOpen, onClose }: AuthModalProps) { const { fetchProfile } = useUserStore(); - const [view, setView] = useState<"login" | "register" | "otp-phone" | "otp" | "forgot-password" | "forgot-otp" | "reset-password" | "wholesale-request">("login"); + const [view, setView] = useState< + | "login" + | "register" + | "otp-phone" + | "otp" + | "forgot-password" + | "forgot-otp" + | "reset-password" + | "wholesale-request" + >("login"); const [medicalLicense, setMedicalLicense] = useState(""); const [businessName, setBusinessName] = useState(""); const [documentUploaded, setDocumentUploaded] = useState(false); @@ -27,6 +50,8 @@ export default function AuthModal({ isOpen, onClose }: AuthModalProps) { const [otpCode, setOtpCode] = useState(""); const [isLoading, setIsLoading] = useState(false); const [countdown, setCountdown] = useState(0); + const [showPassword, setShowPassword] = useState(false); + const [showNewPassword, setShowNewPassword] = useState(false); const otpInputRef = useRef(null); useEffect(() => { @@ -52,6 +77,8 @@ export default function AuthModal({ isOpen, onClose }: AuthModalProps) { setOtpCode(""); setIsLoading(false); setCountdown(0); + setShowPassword(false); + setShowNewPassword(false); }); } }, [isOpen]); @@ -64,13 +91,72 @@ export default function AuthModal({ isOpen, onClose }: AuthModalProps) { } }, [countdown]); + const triggerVerifyOtp = useCallback( + async (codeToVerify: string) => { + const cleanCode = toEnglishDigits(codeToVerify).trim(); + const cleanPhone = toEnglishDigits(phoneNumber).trim(); + if (cleanCode.length !== 5 || !cleanPhone) return; + + setIsLoading(true); + try { + const response = await authService.verifyOtp(cleanPhone, cleanCode); + if (response.success) { + await fetchProfile(); + toast.success("ورود با موفقیت انجام شد"); + onClose(); + } + } catch (err: unknown) { + const errObj = err as { message?: string }; + toast.error(errObj.message || "کد تایید نامعتبر است"); + } finally { + setIsLoading(false); + } + }, + [phoneNumber, fetchProfile, onClose], + ); + + // WebOTP API SMS Auto-read + useEffect(() => { + if (view === "otp" && typeof window !== "undefined" && "OTPCredential" in window) { + const ac = new AbortController(); + (navigator as unknown as { credentials: { get: (opts: unknown) => Promise<{ code?: string }> } }).credentials + ?.get({ + otp: { transport: ["sms"] }, + signal: ac.signal, + }) + .then((otp) => { + if (otp?.code) { + const clean = toEnglishDigits(otp.code).replace(/[^0-9]/g, "").slice(0, 5); + setOtpCode(clean); + if (clean.length === 5) { + triggerVerifyOtp(clean); + } + } + }) + .catch(() => {}); + + return () => { + ac.abort(); + }; + } + }, [view, triggerVerifyOtp]); + + const handleOtpChange = (val: string) => { + const clean = toEnglishDigits(val).replace(/[^0-9]/g, "").slice(0, 5); + setOtpCode(clean); + if (clean.length === 5) { + triggerVerifyOtp(clean); + } + }; + const handleLogin = async (e: React.FormEvent) => { e.preventDefault(); - if (!phoneNumber || !password) return toast.error("شماره موبایل و رمز عبور الزامی است"); + const cleanPhone = toEnglishDigits(phoneNumber).trim(); + if (!cleanPhone || !password) return toast.error("شماره موبایل و رمز عبور الزامی است"); setIsLoading(true); try { - const res = await authService.login(phoneNumber, password); + const res = await authService.login(cleanPhone, password.trim()); if (res.success) { await fetchProfile(); toast.success("با موفقیت وارد شدید"); @@ -86,13 +172,20 @@ export default function AuthModal({ isOpen, onClose }: AuthModalProps) { const handleRegister = async (e: React.FormEvent) => { e.preventDefault(); - if (!firstName || !lastName || !phoneNumber || !password) { + const cleanPhone = toEnglishDigits(phoneNumber).trim(); + if (!firstName || !lastName || !cleanPhone || !password) { return toast.error("لطفاً فیلدهای الزامی را پر کنید"); } setIsLoading(true); try { - const res = await authService.register({ firstName, lastName, mobile: phoneNumber, email, password }); + const res = await authService.register({ + firstName: firstName.trim(), + lastName: lastName.trim(), + mobile: cleanPhone, + email: email ? email.trim() : undefined, + password: password.trim(), + }); if (res.success) { await fetchProfile(); toast.success("ثبت‌نام با موفقیت انجام شد"); @@ -108,7 +201,7 @@ export default function AuthModal({ isOpen, onClose }: AuthModalProps) { const handleSendOtp = async (e: React.FormEvent) => { e.preventDefault(); - const cleanPhone = phoneNumber.trim(); + const cleanPhone = toEnglishDigits(phoneNumber).trim(); if (!/^09\d{9}$/.test(cleanPhone)) { toast.error("شماره موبایل نامعتبر است"); return; @@ -135,33 +228,14 @@ export default function AuthModal({ isOpen, onClose }: AuthModalProps) { const handleVerifyOtp = async (e: React.FormEvent) => { e.preventDefault(); - const cleanCode = otpCode.trim(); - if (cleanCode.length !== 5) { - toast.error("کد باید ۵ رقم باشد"); - return; - } - - setIsLoading(true); - try { - const response = await authService.verifyOtp(phoneNumber.trim(), cleanCode); - if (response.success) { - await fetchProfile(); - toast.success("ورود موفقیت‌آمیز بود"); - onClose(); - } - } catch (err: unknown) { - const errObj = err as { message?: string }; - toast.error(errObj.message || "کد تایید نامعتبر است"); - } finally { - setIsLoading(false); - } + triggerVerifyOtp(otpCode); }; const handleResendOtp = async () => { if (countdown > 0) return; setIsLoading(true); try { - await authService.sendOtp(phoneNumber.trim()); + await authService.sendOtp(toEnglishDigits(phoneNumber).trim()); toast.success("کد تایید جدید ارسال شد"); setCountdown(120); } catch (err: unknown) { @@ -174,7 +248,7 @@ export default function AuthModal({ isOpen, onClose }: AuthModalProps) { const handleForgotSendOtp = async (e: React.FormEvent) => { e.preventDefault(); - const cleanPhone = phoneNumber.trim(); + const cleanPhone = toEnglishDigits(phoneNumber).trim(); if (!/^09\d{9}$/.test(cleanPhone)) { toast.error("شماره موبایل نامعتبر است"); return; @@ -200,14 +274,14 @@ export default function AuthModal({ isOpen, onClose }: AuthModalProps) { const handleForgotVerifyOtp = async (e: React.FormEvent) => { e.preventDefault(); - if (otpCode.trim().length !== 5) { + const cleanCode = toEnglishDigits(otpCode).trim(); + if (cleanCode.length !== 5) { toast.error("کد باید ۵ رقم باشد"); return; } setIsLoading(true); try { - // Verify OTP - if success, allow password reset - await authService.verifyOtp(phoneNumber.trim(), otpCode.trim()); + await authService.verifyOtp(toEnglishDigits(phoneNumber).trim(), cleanCode); setView("reset-password"); } catch (err: unknown) { const errObj = err as { message?: string }; @@ -219,18 +293,18 @@ export default function AuthModal({ isOpen, onClose }: AuthModalProps) { const handleResetPassword = async (e: React.FormEvent) => { e.preventDefault(); - if (newPassword.length < 6) { - toast.error("رمز جدید باید حداقل ۶ کاراکتر باشد"); - return; + if (!newPassword || newPassword.length < 6) { + return toast.error("رمز عبور جدید باید حداقل ۶ کاراکتر باشد"); } setIsLoading(true); try { - await authService.updateProfile({ password: newPassword } as unknown as Parameters[0]); - toast.success("رمز عبور با موفقیت تغییر یافت"); - setView("login"); + await authService.updateProfile({ password: newPassword.trim() }); + await fetchProfile(); + toast.success("رمز عبور جدید با موفقیت تنظیم شد"); + onClose(); } catch (err: unknown) { const errObj = err as { message?: string }; - toast.error(errObj.message || "خطا در تغییر رمز"); + toast.error(errObj.message || "خطا در تغییر رمز عبور"); } finally { setIsLoading(false); } @@ -239,52 +313,36 @@ export default function AuthModal({ isOpen, onClose }: AuthModalProps) { return ( {isOpen && ( - <> - {/* Backdrop */} +
- {/* Modal content */} - {/* Top highlight bar */} -
- -
- {/* Close Button */} + {/* Header */} +
+
+ + ورود امن به کانینو +
+
- {/* Title Header */} -
-
- -
-

- ورود به دنیای کانینا -

-

- {view === "otp" - ? `کد تایید ارسال شده به شماره ${toPersian(phoneNumber)} را وارد کنید` - : "برای دسترسی به پرونده سلامت و سفارشات وارد شوید"} -

-
- - {/* Content Panel */} +
{view === "login" && ( -
- - setPhoneNumber(e.target.value.replace(/[^0-9]/g, ''))} - disabled={isLoading} - className="w-full bg-medical-gray-50 border border-medical-gray-200 rounded-xl py-3 px-4 focus:ring-2 focus:ring-canina-blue/20 outline-none font-bold text-left tracking-widest" - /> -
-
- - setPassword(e.target.value)} - disabled={isLoading} - className="w-full bg-medical-gray-50 border border-medical-gray-200 rounded-xl py-3 px-4 focus:ring-2 focus:ring-canina-blue/20 outline-none text-left" - /> -
- -
- -
+
+
+ +
+
+ + setPhoneNumber(e.target.value.replace(/[^0-9]/g, ""))} + className="w-full bg-medical-gray-50 border border-medical-gray-200 rounded-xl py-3 pr-11 pl-4 focus:ring-2 focus:ring-canina-blue/20 outline-none text-sm text-left font-mono tracking-wider" + dir="ltr" + /> +
+
+ +
+
+ + +
+
+ + setPassword(e.target.value)} + className="w-full bg-medical-gray-50 border border-medical-gray-200 rounded-xl py-3 pr-11 pl-11 focus:ring-2 focus:ring-canina-blue/20 outline-none text-sm text-left font-mono tracking-wider" + dir="ltr" + /> + +
+
+ +
+
+ حساب کاربری ندارید؟
@@ -355,10 +432,10 @@ export default function AuthModal({ isOpen, onClose }: AuthModalProps) {
@@ -380,7 +457,9 @@ export default function AuthModal({ isOpen, onClose }: AuthModalProps) { exit={{ opacity: 0, x: -20 }} onSubmit={(e) => { e.preventDefault(); - toast.success("درخواست احراز هویت خریدار عمده با موفقیت ارسال شد و پس از بررسی ادمین تایید خواهد شد."); + toast.success( + "درخواست احراز هویت خریدار عمده با موفقیت ارسال شد و پس از بررسی ادمین تایید خواهد شد.", + ); setView("login"); }} className="space-y-3" @@ -395,7 +474,7 @@ export default function AuthModal({ isOpen, onClose }: AuthModalProps) { type="text" required value={businessName} - onChange={e => setBusinessName(e.target.value)} + onChange={(e) => setBusinessName(e.target.value)} placeholder="مثال: کلینیک دامپزشکی رازی" className="w-full bg-medical-gray-50 border border-medical-gray-200 rounded-xl py-2 px-3 focus:ring-2 focus:ring-canina-blue/20 outline-none text-sm" /> @@ -407,9 +486,10 @@ export default function AuthModal({ isOpen, onClose }: AuthModalProps) { type="text" required value={medicalLicense} - onChange={e => setMedicalLicense(e.target.value)} + onChange={(e) => setMedicalLicense(e.target.value)} placeholder="مثال: ۹۸۷۶۵۴۳۲۱" - className="w-full bg-medical-gray-50 border border-medical-gray-200 rounded-xl py-2 px-3 focus:ring-2 focus:ring-canina-blue/20 outline-none text-sm text-left" + className="w-full bg-medical-gray-50 border border-medical-gray-200 rounded-xl py-2 px-3 focus:ring-2 focus:ring-canina-blue/20 outline-none text-sm text-left font-mono" + dir="ltr" />
@@ -420,18 +500,21 @@ export default function AuthModal({ isOpen, onClose }: AuthModalProps) { required maxLength={11} value={phoneNumber} - onChange={e => setPhoneNumber(e.target.value.replace(/[^0-9]/g, ''))} + onChange={(e) => setPhoneNumber(e.target.value.replace(/[^0-9]/g, ""))} placeholder="۰۹۱۲..." - className="w-full bg-medical-gray-50 border border-medical-gray-200 rounded-xl py-2 px-3 focus:ring-2 focus:ring-canina-blue/20 outline-none text-sm text-left" + className="w-full bg-medical-gray-50 border border-medical-gray-200 rounded-xl py-2 px-3 focus:ring-2 focus:ring-canina-blue/20 outline-none text-sm text-left font-mono" + dir="ltr" />
-
{ setDocumentUploaded(true); toast.success("تصویر پروانه با موفقیت بارگذاری شد."); }} - className={`p-4 border-2 border-dashed rounded-xl text-center cursor-pointer transition-all ${documentUploaded ? 'border-green-500 bg-green-50' : 'border-medical-gray-300 hover:border-canina-blue'}`} + className={`p-4 border-2 border-dashed rounded-xl text-center cursor-pointer transition-all ${ + documentUploaded ? "border-green-500 bg-green-50" : "border-medical-gray-300 hover:border-canina-blue" + }`} > {documentUploaded ? (
@@ -450,7 +533,7 @@ export default function AuthModal({ isOpen, onClose }: AuthModalProps) { @@ -480,12 +563,10 @@ export default function AuthModal({ isOpen, onClose }: AuthModalProps) { setFirstName(e.target.value)} - disabled={isLoading} className="w-full bg-medical-gray-50 border border-medical-gray-200 rounded-xl py-2 px-3 focus:ring-2 focus:ring-canina-blue/20 outline-none text-sm" />
@@ -493,29 +574,27 @@ export default function AuthModal({ isOpen, onClose }: AuthModalProps) { setLastName(e.target.value)} - disabled={isLoading} className="w-full bg-medical-gray-50 border border-medical-gray-200 rounded-xl py-2 px-3 focus:ring-2 focus:ring-canina-blue/20 outline-none text-sm" />
- + setPhoneNumber(e.target.value.replace(/[^0-9]/g, ''))} - disabled={isLoading} - className="w-full bg-medical-gray-50 border border-medical-gray-200 rounded-xl py-2 px-3 focus:ring-2 focus:ring-canina-blue/20 outline-none text-left tracking-widest text-sm" + onChange={(e) => setPhoneNumber(e.target.value.replace(/[^0-9]/g, ""))} + className="w-full bg-medical-gray-50 border border-medical-gray-200 rounded-xl py-2 px-3 focus:ring-2 focus:ring-canina-blue/20 outline-none text-sm text-left font-mono tracking-wider" + dir="ltr" />
@@ -523,35 +602,45 @@ export default function AuthModal({ isOpen, onClose }: AuthModalProps) { setEmail(e.target.value)} - disabled={isLoading} - className="w-full bg-medical-gray-50 border border-medical-gray-200 rounded-xl py-2 px-3 focus:ring-2 focus:ring-canina-blue/20 outline-none text-left text-sm" + className="w-full bg-medical-gray-50 border border-medical-gray-200 rounded-xl py-2 px-3 focus:ring-2 focus:ring-canina-blue/20 outline-none text-sm text-left font-mono" + dir="ltr" />
- - setPassword(e.target.value)} - disabled={isLoading} - className="w-full bg-medical-gray-50 border border-medical-gray-200 rounded-xl py-2 px-3 focus:ring-2 focus:ring-canina-blue/20 outline-none text-left text-sm" - /> + +
+ setPassword(e.target.value)} + className="w-full bg-medical-gray-50 border border-medical-gray-200 rounded-xl py-2 pr-3 pl-10 focus:ring-2 focus:ring-canina-blue/20 outline-none text-sm text-left font-mono" + dir="ltr" + /> + +
@@ -582,7 +671,7 @@ export default function AuthModal({ isOpen, onClose }: AuthModalProps) { @@ -595,9 +684,10 @@ export default function AuthModal({ isOpen, onClose }: AuthModalProps) { maxLength={11} placeholder="مثال: ۰۹۱۲۳۴۵۶۷۸۹" value={phoneNumber} - onChange={(e) => setPhoneNumber(e.target.value.replace(/[^0-9]/g, ''))} + onChange={(e) => setPhoneNumber(e.target.value.replace(/[^0-9]/g, ""))} disabled={isLoading} - className="w-full bg-medical-gray-50 border border-medical-gray-200 rounded-2xl py-4 pr-12 pl-4 focus:ring-2 focus:ring-canina-blue/20 outline-none font-bold text-lg text-left tracking-widest" + className="w-full bg-medical-gray-50 border border-medical-gray-200 rounded-2xl py-4 pr-12 pl-4 focus:ring-2 focus:ring-canina-blue/20 outline-none font-bold text-lg text-left font-mono tracking-widest" + dir="ltr" />
@@ -634,43 +724,31 @@ export default function AuthModal({ isOpen, onClose }: AuthModalProps) {
- + setOtpCode(e.target.value.replace(/[^0-9]/g, ''))} + onChange={(e) => handleOtpChange(e.target.value)} disabled={isLoading} - className="w-full bg-medical-gray-50 border border-medical-gray-200 rounded-2xl py-4 pr-12 pl-4 focus:ring-2 focus:ring-canina-blue/20 outline-none font-black text-2xl text-center tracking-[0.5em]" + className="w-full bg-medical-gray-50 border border-medical-gray-200 rounded-2xl py-4 pr-12 pl-4 focus:ring-2 focus:ring-canina-blue/20 outline-none font-bold text-center text-2xl font-mono tracking-[0.5em]" + dir="ltr" />
- - -
- {countdown > 0 ? ( - - ارسال مجدد کد پس از {toPersian(countdown)} ثانیه - - ) : ( - - )} +

+ کد پیامک‌شده به صورت خودکار شناسایی و وارد می‌شود. +

+ +
+ {countdown > 0 ? ( + + ارسال مجدد کد تا {toPersian(countdown.toString())} ثانیه دیگر + + ) : ( + + )} +
)} - {/* Forgot Password - Enter Phone */} {view === "forgot-password" && ( -
- -

برای بازیابی رمز عبور، کد تایید به شمارهتان ارسال می‌شود

+
+ +
+ + setPhoneNumber(e.target.value.replace(/[^0-9]/g, ""))} + className="w-full bg-medical-gray-50 border border-medical-gray-200 rounded-xl py-3 pr-11 pl-4 focus:ring-2 focus:ring-canina-blue/20 outline-none text-sm text-left font-mono tracking-wider" + dir="ltr" + /> +
-
- - setPhoneNumber(e.target.value.replace(/[^0-9]/g, ''))} - disabled={isLoading} - className="w-full bg-medical-gray-50 border border-medical-gray-200 rounded-xl py-3 px-4 focus:ring-2 focus:ring-canina-blue/20 outline-none font-bold text-left tracking-widest" - /> + +
+
+ - )} - {/* Forgot Password - Enter OTP */} {view === "forgot-otp" && ( -

کد ارسال شده به {toPersian(phoneNumber)} را وارد کنید

-
- setOtpCode(e.target.value.replace(/[^0-9]/g, ''))} - disabled={isLoading} - className="w-full bg-medical-gray-50 border border-medical-gray-200 rounded-2xl py-4 pl-4 pr-4 focus:ring-2 focus:ring-canina-blue/20 outline-none font-black text-2xl text-center tracking-[0.5em]" - /> +
+ +
+ + setOtpCode(toEnglishDigits(e.target.value).replace(/[^0-9]/g, "").slice(0, 5))} + className="w-full bg-medical-gray-50 border border-medical-gray-200 rounded-xl py-3 pr-11 pl-4 focus:ring-2 focus:ring-canina-blue/20 outline-none text-center font-mono font-bold text-xl tracking-[0.4em]" + dir="ltr" + /> +
- {countdown > 0 ? ( -

ارسال مجدد پس از {toPersian(countdown)} ثانیه

- ) : ( - - )} - )} - {/* Reset Password */} {view === "reset-password" && ( -
-

هویت تایید شد. رمز جدید خود را وارد کنید

+
+ +
+ + setNewPassword(e.target.value)} + className="w-full bg-medical-gray-50 border border-medical-gray-200 rounded-xl py-3 pr-11 pl-11 focus:ring-2 focus:ring-canina-blue/20 outline-none text-sm text-left font-mono" + dir="ltr" + /> + +
-
- - setNewPassword(e.target.value)} - disabled={isLoading} - className="w-full bg-medical-gray-50 border border-medical-gray-200 rounded-xl py-3 px-4 focus:ring-2 focus:ring-canina-blue/20 outline-none text-left" - /> -
- )} -
- +
)} ); diff --git a/frontend/application/components/UserDashboard.tsx b/frontend/application/components/UserDashboard.tsx index 996a611..3072489 100644 --- a/frontend/application/components/UserDashboard.tsx +++ b/frontend/application/components/UserDashboard.tsx @@ -1,7 +1,7 @@ "use client"; import React, { useState } from "react"; import { motion } from "motion/react"; -import { User, ShoppingBag, Wallet, MapPin, LogOut, ChevronRight, Package, Calendar, UserCircle, ShoppingCart, Trash2, Edit2, Phone, Hash, CheckCircle2, ArrowUpCircle, ArrowDownCircle, Info, Clock, CheckCircle, Heart, Sparkles, MessageSquare, Send, Stethoscope, FileText } from "lucide-react"; +import { User, ShoppingBag, Wallet, MapPin, LogOut, ChevronRight, Package, Calendar, UserCircle, ShoppingCart, Trash2, Edit2, Phone, Hash, CheckCircle2, ArrowUpCircle, ArrowDownCircle, Info, Clock, CheckCircle, Heart, Sparkles, MessageSquare, Send, Stethoscope, FileText, Lock, Key, Eye, EyeOff, ShieldCheck } from "lucide-react"; import { OrderRowSkeleton } from "./Skeleton"; import { useUserStore, Address } from "../lib/store/userStore"; import { useCartStore } from "../lib/store/cartStore"; @@ -107,6 +107,48 @@ export default function UserDashboard() { } }; + // Password Management State + const [currentPassword, setCurrentPassword] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [showCurrentPass, setShowCurrentPass] = useState(false); + const [showNewPass, setShowNewPass] = useState(false); + const [showConfirmPass, setShowConfirmPass] = useState(false); + const [isSavingPassword, setIsSavingPassword] = useState(false); + + const handleSavePassword = async (e: React.FormEvent) => { + e.preventDefault(); + if (!newPassword || newPassword.length < 6) { + toast.error("رمز عبور جدید باید حداقل ۶ کاراکتر باشد"); + return; + } + if (newPassword !== confirmPassword) { + toast.error("رمز عبور جدید و تکرار آن یکسان نیستند"); + return; + } + if (profile.hasPassword && !currentPassword) { + toast.error("لطفاً رمز عبور فعلی خود را وارد کنید"); + return; + } + + setIsSavingPassword(true); + try { + await updateProfile({ + password: newPassword.trim(), + currentPassword: profile.hasPassword ? currentPassword.trim() : undefined, + }); + toast.success(profile.hasPassword ? "رمز عبور با موفقیت تغییر کرد" : "رمز عبور با موفقیت برای حساب شما ثبت شد"); + setCurrentPassword(""); + setNewPassword(""); + setConfirmPassword(""); + } catch (err: unknown) { + const errObj = err as { message?: string }; + toast.error(errObj.message || "خطا در ثبت رمز عبور"); + } finally { + setIsSavingPassword(false); + } + }; + const handleLogout = () => { logout(); router.push('/'); @@ -339,9 +381,140 @@ export default function UserDashboard() { )}
- -
- )} + + + {/* Password & Security Section */} +
+
+
+
+ +
+
+

+ {profile.hasPassword ? "تغییر رمز عبور حساب" : "تعریف رمز عبور برای حساب کاربری"} +

+

+ {profile.hasPassword + ? "برای افزایش امنیت می‌توانید رمز عبور ورود خود را تغییر دهید." + : "با تعیین رمز عبور می‌توانید در دفعات بعدی علاوه بر پیامک، با رمز عبور نیز وارد شوید."} +

+
+
+ + + {profile.hasPassword ? "رمز عبور فعال است" : "ورود فقط با OTP"} + +
+ +
+
+ {profile.hasPassword && ( +
+ +
+ setCurrentPassword(e.target.value)} + className="w-full bg-white border border-medical-gray-200 rounded-xl py-3 pr-3 pl-10 focus:ring-2 focus:ring-canina-blue/20 outline-none text-sm text-left font-mono" + dir="ltr" + /> + +
+
+ )} + +
+ +
+ setNewPassword(e.target.value)} + className="w-full bg-white border border-medical-gray-200 rounded-xl py-3 pr-3 pl-10 focus:ring-2 focus:ring-canina-blue/20 outline-none text-sm text-left font-mono" + dir="ltr" + /> + +
+
+ +
+ +
+ setConfirmPassword(e.target.value)} + className="w-full bg-white border border-medical-gray-200 rounded-xl py-3 pr-3 pl-10 focus:ring-2 focus:ring-canina-blue/20 outline-none text-sm text-left font-mono" + dir="ltr" + /> + +
+
+
+ +
+ +
+
+
+ + )} {activeTab === "orders" && (
diff --git a/frontend/application/lib/services/authService.ts b/frontend/application/lib/services/authService.ts index 2be5985..ea260b1 100644 --- a/frontend/application/lib/services/authService.ts +++ b/frontend/application/lib/services/authService.ts @@ -9,6 +9,9 @@ export interface User { role: string; walletBalance: number; charityDonationTotal: number; + hasPassword?: boolean; + password?: string; + currentPassword?: string; createdAt: string; updatedAt: string; pets?: unknown[]; diff --git a/frontend/application/lib/store/userStore.ts b/frontend/application/lib/store/userStore.ts index aacdf7c..06f84d1 100644 --- a/frontend/application/lib/store/userStore.ts +++ b/frontend/application/lib/store/userStore.ts @@ -1,7 +1,7 @@ "use client"; import { create } from "zustand"; import { persist } from "zustand/middleware"; -import { authService } from "../services/authService"; +import { authService, User } from "../services/authService"; import api from "../services/api"; import { useCartStore } from "./cartStore"; import { usePetStore } from "./usePetStore"; @@ -37,6 +37,7 @@ interface UserProfile { mobile: string; walletBalance: number; charityDonationTotal: number; + hasPassword?: boolean; addresses: Address[]; transactions: Transaction[]; } @@ -49,7 +50,7 @@ interface UserStore { setRole: (role: UserRole) => void; setLoggedIn: (isLoggedIn: boolean) => void; setAuthModalOpen: (open: boolean) => void; - updateProfile: (profile: Partial) => Promise; + updateProfile: (profile: Partial) => Promise; addAddress: (address: Address) => Promise; updateAddress: (id: string, address: Address) => Promise; deleteAddress: (id: string) => Promise; @@ -72,6 +73,7 @@ export const useUserStore = create()( mobile: "", walletBalance: 0, charityDonationTotal: 0, + hasPassword: false, addresses: [], transactions: [], }, @@ -80,7 +82,7 @@ export const useUserStore = create()( setAuthModalOpen: (open) => set({ isAuthModalOpen: open }), updateProfile: async (updates) => { try { - const profileData = await authService.updateProfile(updates); + const profileData = await authService.updateProfile(updates as Partial); set((state) => ({ profile: { ...state.profile, @@ -88,6 +90,7 @@ export const useUserStore = create()( lastName: profileData.lastName, email: profileData.email, mobile: profileData.mobile || "", + hasPassword: profileData.hasPassword !== undefined ? profileData.hasPassword : state.profile.hasPassword, } })); } catch (error) { @@ -246,6 +249,7 @@ export const useUserStore = create()( mobile: profileData.mobile || "", walletBalance: backendWallet, charityDonationTotal: backendCharity, + hasPassword: profileData.hasPassword !== undefined ? profileData.hasPassword : state.profile.hasPassword, addresses: Array.isArray(pd.addresses) && pd.addresses.length > 0 ? (pd.addresses as Address[]) : state.profile.addresses, transactions: backendTransactions.length > 0 ? backendTransactions : state.profile.transactions }