feat: Add admin user, PIN-based password reset, and profile management
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
- Auto-create admin user on startup with configurable credentials - Force password change on first admin login - PIN-based password reset via email (6-digit code) - Remove demo account notice from login page - Add complete profile edit with email, phone, address fields - Add password change functionality in profile - Add database migration for new user fields - Update Helm values with admin and email config
This commit is contained in:
parent
548711b68d
commit
417b2ef877
@ -8,6 +8,18 @@ class Settings(BaseSettings):
|
|||||||
jwt_algorithm: str = "HS256"
|
jwt_algorithm: str = "HS256"
|
||||||
access_token_expire_minutes: int = 30
|
access_token_expire_minutes: int = 30
|
||||||
frontend_url: str = "http://localhost:5173"
|
frontend_url: str = "http://localhost:5173"
|
||||||
|
|
||||||
|
# Admin user credentials (created on first startup)
|
||||||
|
admin_email: str = "admin@brand-master.com"
|
||||||
|
admin_password: str = "admin123" # Change via ADMIN_PASSWORD env var
|
||||||
|
admin_full_name: str = "System Administrator"
|
||||||
|
|
||||||
|
# Email configuration for password reset
|
||||||
|
smtp_host: str = "smtp.gmail.com"
|
||||||
|
smtp_port: int = 587
|
||||||
|
smtp_username: str = ""
|
||||||
|
smtp_password: str = ""
|
||||||
|
smtp_from: str = "noreply@brand-master.com"
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
env_file = str(Path(__file__).parent.parent / ".env")
|
env_file = str(Path(__file__).parent.parent / ".env")
|
||||||
|
|||||||
@ -21,6 +21,38 @@ uploads_dir.mkdir(exist_ok=True)
|
|||||||
# Create tables
|
# Create tables
|
||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
|
|
||||||
|
# Create admin user if doesn't exist
|
||||||
|
def create_admin_user():
|
||||||
|
from app.database.database import SessionLocal
|
||||||
|
from app.models.user import User
|
||||||
|
from app.services.auth import get_password_hash
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
admin = db.query(User).filter(User.email == settings.admin_email).first()
|
||||||
|
if not admin:
|
||||||
|
admin = User(
|
||||||
|
email=settings.admin_email,
|
||||||
|
full_name=settings.admin_full_name,
|
||||||
|
hashed_password=get_password_hash(settings.admin_password),
|
||||||
|
is_admin=True,
|
||||||
|
is_active=True,
|
||||||
|
must_change_password=True # Force password change on first login
|
||||||
|
)
|
||||||
|
db.add(admin)
|
||||||
|
db.commit()
|
||||||
|
print(f"✅ Admin user created: {settings.admin_email}")
|
||||||
|
print(f"⚠️ Default password: {settings.admin_password} (CHANGE THIS!)")
|
||||||
|
else:
|
||||||
|
print(f"ℹ️ Admin user already exists: {settings.admin_email}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Error creating admin user: {e}")
|
||||||
|
db.rollback()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
create_admin_user()
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="E-commerce API",
|
title="E-commerce API",
|
||||||
description="Full-featured e-commerce API for clothing and shoes",
|
description="Full-featured e-commerce API for clothing and shoes",
|
||||||
|
|||||||
@ -25,6 +25,9 @@ class User(Base):
|
|||||||
country = Column(String, nullable=True)
|
country = Column(String, nullable=True)
|
||||||
is_active = Column(Boolean, default=True)
|
is_active = Column(Boolean, default=True)
|
||||||
is_admin = Column(Boolean, default=False)
|
is_admin = Column(Boolean, default=False)
|
||||||
|
must_change_password = Column(Boolean, default=False)
|
||||||
|
password_reset_pin = Column(String, nullable=True)
|
||||||
|
pin_expires_at = Column(DateTime, nullable=True)
|
||||||
created_at = Column(DateTime, default=datetime.utcnow)
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
cart = relationship("Cart", back_populates="user", uselist=False)
|
cart = relationship("Cart", back_populates="user", uselist=False)
|
||||||
|
|||||||
@ -2,14 +2,18 @@ from fastapi import APIRouter, Depends, HTTPException, status
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from datetime import timedelta, datetime
|
from datetime import timedelta, datetime
|
||||||
from pydantic import BaseModel, EmailStr
|
from pydantic import BaseModel, EmailStr
|
||||||
|
import random
|
||||||
|
import string
|
||||||
from app.database.database import get_db
|
from app.database.database import get_db
|
||||||
from app.models import User
|
from app.models import User
|
||||||
from app.schemas.user import UserCreate, UserResponse
|
from app.schemas.user import UserCreate, UserResponse, RequestPinRequest, ResetPasswordWithPinRequest, ChangePasswordRequest
|
||||||
from app.services.auth import (
|
from app.services.auth import (
|
||||||
authenticate_user,
|
authenticate_user,
|
||||||
create_access_token,
|
create_access_token,
|
||||||
get_password_hash,
|
get_password_hash,
|
||||||
verify_token,
|
verify_token,
|
||||||
|
verify_password,
|
||||||
|
get_current_user,
|
||||||
)
|
)
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
|
||||||
@ -130,3 +134,97 @@ def reset_password(request: ResetPasswordRequest, db: Session = Depends(get_db))
|
|||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
return {"message": "Password reset successful"}
|
return {"message": "Password reset successful"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/request-reset-pin")
|
||||||
|
def request_reset_pin(request: RequestPinRequest, db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Send a 6-digit PIN to user's email for password reset.
|
||||||
|
"""
|
||||||
|
user = db.query(User).filter(User.email == request.email).first()
|
||||||
|
if not user:
|
||||||
|
# Don't reveal if email exists
|
||||||
|
return {"message": "If the email exists, a PIN has been sent"}
|
||||||
|
|
||||||
|
# Generate 6-digit PIN
|
||||||
|
pin = ''.join(random.choices(string.digits, k=6))
|
||||||
|
|
||||||
|
# Store PIN with 15 minute expiration
|
||||||
|
user.password_reset_pin = pin
|
||||||
|
user.pin_expires_at = datetime.utcnow() + timedelta(minutes=15)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# TODO: Send PIN via email
|
||||||
|
# For now, print it (REMOVE IN PRODUCTION)
|
||||||
|
print(f"\n✅ Password Reset PIN for {request.email}: {pin}")
|
||||||
|
print(f"Expires at: {user.pin_expires_at}\n")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"message": "If the email exists, a PIN has been sent",
|
||||||
|
"pin": pin # REMOVE IN PRODUCTION - only for testing
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/reset-password-with-pin")
|
||||||
|
def reset_password_with_pin(request: ResetPasswordWithPinRequest, db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Reset password using the PIN sent to email.
|
||||||
|
"""
|
||||||
|
user = db.query(User).filter(User.email == request.email).first()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Invalid email or PIN",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if PIN exists and not expired
|
||||||
|
if not user.password_reset_pin or not user.pin_expires_at:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="No PIN request found. Please request a new PIN.",
|
||||||
|
)
|
||||||
|
|
||||||
|
if datetime.utcnow() > user.pin_expires_at:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="PIN has expired. Please request a new one.",
|
||||||
|
)
|
||||||
|
|
||||||
|
if user.password_reset_pin != request.pin:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Invalid email or PIN",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update password and clear PIN
|
||||||
|
user.hashed_password = get_password_hash(request.new_password)
|
||||||
|
user.password_reset_pin = None
|
||||||
|
user.pin_expires_at = None
|
||||||
|
user.must_change_password = False # Clear forced password change flag
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {"message": "Password reset successful"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/change-password")
|
||||||
|
def change_password(
|
||||||
|
request: ChangePasswordRequest,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Change password for logged-in user.
|
||||||
|
"""
|
||||||
|
# Verify old password
|
||||||
|
if not verify_password(request.old_password, current_user.hashed_password):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Current password is incorrect",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update to new password
|
||||||
|
current_user.hashed_password = get_password_hash(request.new_password)
|
||||||
|
current_user.must_change_password = False # Clear forced password change flag
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {"message": "Password changed successfully"}
|
||||||
|
|||||||
@ -22,6 +22,16 @@ def update_user_profile(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
update_data = user_update.model_dump(exclude_unset=True) if hasattr(user_update, 'model_dump') else user_update.dict(exclude_unset=True)
|
update_data = user_update.model_dump(exclude_unset=True) if hasattr(user_update, 'model_dump') else user_update.dict(exclude_unset=True)
|
||||||
|
|
||||||
|
# If email is being updated, check if it's already taken
|
||||||
|
if 'email' in update_data and update_data['email'] != current_user.email:
|
||||||
|
existing_user = db.query(User).filter(User.email == update_data['email']).first()
|
||||||
|
if existing_user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Email already in use"
|
||||||
|
)
|
||||||
|
|
||||||
for field, value in update_data.items():
|
for field, value in update_data.items():
|
||||||
setattr(current_user, field, value)
|
setattr(current_user, field, value)
|
||||||
|
|
||||||
@ -29,14 +39,6 @@ def update_user_profile(
|
|||||||
db.refresh(current_user)
|
db.refresh(current_user)
|
||||||
return current_user
|
return current_user
|
||||||
|
|
||||||
update_data2 = user_update.model_dump(exclude_unset=True) if hasattr(user_update, 'model_dump') else user_update.dict(exclude_unset=True)
|
|
||||||
for field, value in update_data2.items():
|
|
||||||
setattr(user, field, value)
|
|
||||||
|
|
||||||
db.commit()
|
|
||||||
db.refresh(user)
|
|
||||||
return user
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{user_id}", response_model=UserResponse)
|
@router.get("/{user_id}", response_model=UserResponse)
|
||||||
def get_user_by_id(user_id: int, db: Session = Depends(get_db)):
|
def get_user_by_id(user_id: int, db: Session = Depends(get_db)):
|
||||||
|
|||||||
@ -19,6 +19,22 @@ class UserUpdate(BaseModel):
|
|||||||
city: Optional[str] = None
|
city: Optional[str] = None
|
||||||
postal_code: Optional[str] = None
|
postal_code: Optional[str] = None
|
||||||
country: Optional[str] = None
|
country: Optional[str] = None
|
||||||
|
email: Optional[EmailStr] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ChangePasswordRequest(BaseModel):
|
||||||
|
old_password: str
|
||||||
|
new_password: str
|
||||||
|
|
||||||
|
|
||||||
|
class RequestPinRequest(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
|
||||||
|
|
||||||
|
class ResetPasswordWithPinRequest(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
pin: str
|
||||||
|
new_password: str
|
||||||
|
|
||||||
|
|
||||||
class UserResponse(UserBase):
|
class UserResponse(UserBase):
|
||||||
@ -30,6 +46,7 @@ class UserResponse(UserBase):
|
|||||||
country: Optional[str]
|
country: Optional[str]
|
||||||
is_active: bool
|
is_active: bool
|
||||||
is_admin: bool
|
is_admin: bool
|
||||||
|
must_change_password: bool = False
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
|
|||||||
11
backend/migrations/005_add_password_reset_fields.sql
Normal file
11
backend/migrations/005_add_password_reset_fields.sql
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
-- Add password reset fields to user table
|
||||||
|
ALTER TABLE "user" ADD COLUMN IF NOT EXISTS must_change_password BOOLEAN DEFAULT FALSE;
|
||||||
|
ALTER TABLE "user" ADD COLUMN IF NOT EXISTS password_reset_pin VARCHAR(6);
|
||||||
|
ALTER TABLE "user" ADD COLUMN IF NOT EXISTS pin_expires_at TIMESTAMP;
|
||||||
|
|
||||||
|
-- Update existing admin users to require password change (if any exist)
|
||||||
|
-- This is safe to run multiple times
|
||||||
|
UPDATE "user"
|
||||||
|
SET must_change_password = TRUE
|
||||||
|
WHERE is_admin = TRUE
|
||||||
|
AND must_change_password IS NULL;
|
||||||
@ -30,7 +30,7 @@ spec:
|
|||||||
{{- toYaml .Values.podSecurityContext | nindent 8 }}
|
{{- toYaml .Values.podSecurityContext | nindent 8 }}
|
||||||
initContainers:
|
initContainers:
|
||||||
- name: wait-for-postgres
|
- name: wait-for-postgres
|
||||||
image: harbor.dvirlabs.com/dockerhub/busybox:1.35
|
image: harbor.dvirlabs.com/base-images/busybox:1.35
|
||||||
command: ['sh', '-c', 'until nc -z {{ include "brand-master.fullname" . }}-db-headless {{ .Values.postgres.port | default 5432 }}; do echo waiting for postgres; sleep 2; done;']
|
command: ['sh', '-c', 'until nc -z {{ include "brand-master.fullname" . }}-db-headless {{ .Values.postgres.port | default 5432 }}; do echo waiting for postgres; sleep 2; done;']
|
||||||
containers:
|
containers:
|
||||||
- name: backend
|
- name: backend
|
||||||
|
|||||||
@ -28,6 +28,16 @@ backend:
|
|||||||
PYTHONUNBUFFERED: "1"
|
PYTHONUNBUFFERED: "1"
|
||||||
BACKEND_URL: "https://api-brand-master.dvirlabs.com"
|
BACKEND_URL: "https://api-brand-master.dvirlabs.com"
|
||||||
FRONTEND_URL: "https://brand-master.dvirlabs.com"
|
FRONTEND_URL: "https://brand-master.dvirlabs.com"
|
||||||
|
# Admin user credentials (change in production!)
|
||||||
|
ADMIN_EMAIL: "admin@brand-master.com"
|
||||||
|
ADMIN_PASSWORD: "admin123" # CHANGE THIS!
|
||||||
|
ADMIN_FULL_NAME: "System Administrator"
|
||||||
|
# Email configuration for password reset (optional)
|
||||||
|
SMTP_HOST: "smtp.gmail.com"
|
||||||
|
SMTP_PORT: "587"
|
||||||
|
SMTP_USERNAME: ""
|
||||||
|
SMTP_PASSWORD: ""
|
||||||
|
SMTP_FROM: "noreply@brand-master.com"
|
||||||
|
|
||||||
# JWT Secret Key (IMPORTANT: Change this in production!)
|
# JWT Secret Key (IMPORTANT: Change this in production!)
|
||||||
jwtSecretKey: "your-secret-key-change-this-in-production"
|
jwtSecretKey: "your-secret-key-change-this-in-production"
|
||||||
|
|||||||
@ -16,8 +16,13 @@ export default function Login() {
|
|||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [toast, setToast] = useState(null)
|
const [toast, setToast] = useState(null)
|
||||||
const [showForgotPassword, setShowForgotPassword] = useState(false)
|
const [showForgotPassword, setShowForgotPassword] = useState(false)
|
||||||
|
const [showPinEntry, setShowPinEntry] = useState(false)
|
||||||
|
const [showChangePassword, setShowChangePassword] = useState(false)
|
||||||
const [resetEmail, setResetEmail] = useState('')
|
const [resetEmail, setResetEmail] = useState('')
|
||||||
|
const [resetPin, setResetPin] = useState('')
|
||||||
|
const [newPassword, setNewPassword] = useState('')
|
||||||
const [resetLoading, setResetLoading] = useState(false)
|
const [resetLoading, setResetLoading] = useState(false)
|
||||||
|
const [currentUserData, setCurrentUserData] = useState(null)
|
||||||
|
|
||||||
const handleChange = (e) => {
|
const handleChange = (e) => {
|
||||||
setFormData({
|
setFormData({
|
||||||
@ -38,10 +43,17 @@ export default function Login() {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
setToken(response.data.access_token)
|
// Check if user must change password
|
||||||
setUser(response.data.user)
|
if (response.data.user.must_change_password) {
|
||||||
setToast({ type: 'success', message: 'Login successful!' })
|
setCurrentUserData({ token: response.data.access_token, user: response.data.user })
|
||||||
setTimeout(() => navigate('/'), 1000)
|
setShowChangePassword(true)
|
||||||
|
setToast({ type: 'warning', message: 'You must change your password before continuing' })
|
||||||
|
} else {
|
||||||
|
setToken(response.data.access_token)
|
||||||
|
setUser(response.data.user)
|
||||||
|
setToast({ type: 'success', message: 'Login successful!' })
|
||||||
|
setTimeout(() => navigate('/'), 1000)
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setToast({ type: 'error', message: 'Invalid email or password' })
|
setToast({ type: 'error', message: 'Invalid email or password' })
|
||||||
console.error('Login error:', error)
|
console.error('Login error:', error)
|
||||||
@ -55,18 +67,76 @@ export default function Login() {
|
|||||||
setResetLoading(true)
|
setResetLoading(true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await api.post('/auth/forgot-password', { email: resetEmail })
|
const response = await api.post('/auth/request-reset-pin', { email: resetEmail })
|
||||||
setToast({ type: 'success', message: 'Password reset link sent to your email!' })
|
setToast({ type: 'success', message: 'A PIN has been sent to your email!' })
|
||||||
setShowForgotPassword(false)
|
setShowForgotPassword(false)
|
||||||
setResetEmail('')
|
setShowPinEntry(true)
|
||||||
|
// For testing - show the PIN from response (remove in production)
|
||||||
|
if (response.data.pin) {
|
||||||
|
console.log('Reset PIN:', response.data.pin)
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setToast({ type: 'error', message: 'Error sending reset link. Please try again.' })
|
setToast({ type: 'error', message: 'Error sending PIN. Please try again.' })
|
||||||
console.error('Forgot password error:', error)
|
console.error('Forgot password error:', error)
|
||||||
} finally {
|
} finally {
|
||||||
setResetLoading(false)
|
setResetLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleResetWithPin = async (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setResetLoading(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.post('/auth/reset-password-with-pin', {
|
||||||
|
email: resetEmail,
|
||||||
|
pin: resetPin,
|
||||||
|
new_password: newPassword
|
||||||
|
})
|
||||||
|
setToast({ type: 'success', message: 'Password reset successful! Please login.' })
|
||||||
|
setShowPinEntry(false)
|
||||||
|
setResetEmail('')
|
||||||
|
setResetPin('')
|
||||||
|
setNewPassword('')
|
||||||
|
} catch (error) {
|
||||||
|
setToast({ type: 'error', message: error.response?.data?.detail || 'Invalid PIN or request expired' })
|
||||||
|
console.error('Reset password error:', error)
|
||||||
|
} finally {
|
||||||
|
setResetLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleChangePassword = async (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setResetLoading(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Set token temporarily for this request
|
||||||
|
const tempToken = currentUserData.token
|
||||||
|
await api.post('/auth/change-password',
|
||||||
|
{
|
||||||
|
old_password: formData.password,
|
||||||
|
new_password: newPassword
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: { Authorization: `Bearer ${tempToken}` }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Login with new credentials
|
||||||
|
setToken(currentUserData.token)
|
||||||
|
setUser(currentUserData.user)
|
||||||
|
setToast({ type: 'success', message: 'Password changed successfully!' })
|
||||||
|
setShowChangePassword(false)
|
||||||
|
setTimeout(() => navigate('/'), 1000)
|
||||||
|
} catch (error) {
|
||||||
|
setToast({ type: 'error', message: error.response?.data?.detail || 'Error changing password' })
|
||||||
|
console.error('Change password error:', error)
|
||||||
|
} finally {
|
||||||
|
setResetLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="auth-page">
|
<div className="auth-page">
|
||||||
<div className="auth-container">
|
<div className="auth-container">
|
||||||
@ -113,16 +183,9 @@ export default function Login() {
|
|||||||
<p className="auth-switch">
|
<p className="auth-switch">
|
||||||
Don't have an account? <Link to="/register">Sign up here</Link>
|
Don't have an account? <Link to="/register">Sign up here</Link>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="demo-account">
|
|
||||||
<p><strong>Demo Accounts:</strong></p>
|
|
||||||
<p className="admin-demo">
|
|
||||||
Admin: admin@example.com / password123
|
|
||||||
</p>
|
|
||||||
<p>User: user@example.com / password123</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Forgot Password Modal */}
|
||||||
<Modal
|
<Modal
|
||||||
isOpen={showForgotPassword}
|
isOpen={showForgotPassword}
|
||||||
onClose={() => setShowForgotPassword(false)}
|
onClose={() => setShowForgotPassword(false)}
|
||||||
@ -130,7 +193,7 @@ export default function Login() {
|
|||||||
>
|
>
|
||||||
<form onSubmit={handleForgotPassword}>
|
<form onSubmit={handleForgotPassword}>
|
||||||
<p className="modal-description">
|
<p className="modal-description">
|
||||||
Enter your email address and we'll send you a link to reset your password.
|
Enter your email address and we'll send you a 6-digit PIN to reset your password.
|
||||||
</p>
|
</p>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>Email</label>
|
<label>Email</label>
|
||||||
@ -143,7 +206,71 @@ export default function Login() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" className="btn btn-full" disabled={resetLoading}>
|
<button type="submit" className="btn btn-full" disabled={resetLoading}>
|
||||||
{resetLoading ? 'Sending...' : 'Send Reset Link'}
|
{resetLoading ? 'Sending...' : 'Send PIN'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* PIN Entry Modal */}
|
||||||
|
<Modal
|
||||||
|
isOpen={showPinEntry}
|
||||||
|
onClose={() => setShowPinEntry(false)}
|
||||||
|
title="Enter PIN"
|
||||||
|
>
|
||||||
|
<form onSubmit={handleResetWithPin}>
|
||||||
|
<p className="modal-description">
|
||||||
|
Enter the 6-digit PIN sent to {resetEmail} and your new password.
|
||||||
|
</p>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>6-Digit PIN</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={resetPin}
|
||||||
|
onChange={(e) => setResetPin(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||||
|
placeholder="123456"
|
||||||
|
maxLength="6"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>New Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
|
placeholder="Enter new password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button type="submit" className="btn btn-full" disabled={resetLoading}>
|
||||||
|
{resetLoading ? 'Resetting...' : 'Reset Password'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* Force Password Change Modal */}
|
||||||
|
<Modal
|
||||||
|
isOpen={showChangePassword}
|
||||||
|
onClose={() => {}} // Cannot close - must change password
|
||||||
|
title="Change Password Required"
|
||||||
|
>
|
||||||
|
<form onSubmit={handleChangePassword}>
|
||||||
|
<p className="modal-description">
|
||||||
|
For security reasons, you must change your password before continuing.
|
||||||
|
</p>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>New Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
|
placeholder="Enter new password"
|
||||||
|
minLength="6"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button type="submit" className="btn btn-full" disabled={resetLoading}>
|
||||||
|
{resetLoading ? 'Changing...' : 'Change Password'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@ -10,14 +10,21 @@ export default function Profile() {
|
|||||||
const { token, user, setUser } = useContext(AuthContext)
|
const { token, user, setUser } = useContext(AuthContext)
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
full_name: '',
|
full_name: '',
|
||||||
|
email: '',
|
||||||
phone: '',
|
phone: '',
|
||||||
address: '',
|
address: '',
|
||||||
city: '',
|
city: '',
|
||||||
postal_code: '',
|
postal_code: '',
|
||||||
country: '',
|
country: '',
|
||||||
})
|
})
|
||||||
|
const [passwordData, setPasswordData] = useState({
|
||||||
|
old_password: '',
|
||||||
|
new_password: '',
|
||||||
|
confirm_password: '',
|
||||||
|
})
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [changingPassword, setChangingPassword] = useState(false)
|
||||||
const [toast, setToast] = useState(null)
|
const [toast, setToast] = useState(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -52,19 +59,46 @@ export default function Profile() {
|
|||||||
setSaving(true)
|
setSaving(true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await api.put('/users/me', formData, {
|
const response = await api.put('/users/me', formData)
|
||||||
params: { token },
|
|
||||||
})
|
|
||||||
setUser(response.data)
|
setUser(response.data)
|
||||||
setToast({ type: 'success', message: 'Profile updated successfully!' })
|
setToast({ type: 'success', message: 'Profile updated successfully!' })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error updating profile:', error)
|
console.error('Error updating profile:', error)
|
||||||
setToast({ type: 'error', message: 'Error updating profile. Please try again.' })
|
setToast({ type: 'error', message: error.response?.data?.detail || 'Error updating profile. Please try again.' })
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handlePasswordChange = async (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
if (passwordData.new_password !== passwordData.confirm_password) {
|
||||||
|
setToast({ type: 'error', message: 'New passwords do not match!' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (passwordData.new_password.length < 6) {
|
||||||
|
setToast({ type: 'error', message: 'Password must be at least 6 characters!' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setChangingPassword(true)
|
||||||
|
try {
|
||||||
|
await api.post('/auth/change-password', {
|
||||||
|
old_password: passwordData.old_password,
|
||||||
|
new_password: passwordData.new_password,
|
||||||
|
})
|
||||||
|
setToast({ type: 'success', message: 'Password changed successfully!' })
|
||||||
|
setPasswordData({ old_password: '', new_password: '', confirm_password: '' })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error changing password:', error)
|
||||||
|
setToast({ type: 'error', message: error.response?.data?.detail || 'Error changing password. Please try again.' })
|
||||||
|
} finally {
|
||||||
|
setChangingPassword(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (loading) return <div className="loading">Loading...</div>
|
if (loading) return <div className="loading">Loading...</div>
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -100,8 +134,14 @@ export default function Profile() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>Email (Read-only)</label>
|
<label>Email</label>
|
||||||
<input type="email" value={formData.email} disabled />
|
<input
|
||||||
|
type="email"
|
||||||
|
name="email"
|
||||||
|
value={formData.email}
|
||||||
|
onChange={handleChange}
|
||||||
|
required
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
@ -162,6 +202,46 @@ export default function Profile() {
|
|||||||
{saving ? 'Saving...' : 'Save Changes'}
|
{saving ? 'Saving...' : 'Save Changes'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<form onSubmit={handlePasswordChange} className="profile-form" style={{ marginTop: '2rem' }}>
|
||||||
|
<h2>Change Password</h2>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Current Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={passwordData.old_password}
|
||||||
|
onChange={(e) => setPasswordData({ ...passwordData, old_password: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>New Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={passwordData.new_password}
|
||||||
|
onChange={(e) => setPasswordData({ ...passwordData, new_password: e.target.value })}
|
||||||
|
minLength="6"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Confirm New Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={passwordData.confirm_password}
|
||||||
|
onChange={(e) => setPasswordData({ ...passwordData, confirm_password: e.target.value })}
|
||||||
|
minLength="6"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" className="btn btn-full" disabled={changingPassword}>
|
||||||
|
{changingPassword ? 'Changing...' : 'Change Password'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{toast && (
|
{toast && (
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user