diff --git a/backend/app/config.py b/backend/app/config.py index 8ea8a61..3878f84 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -8,6 +8,18 @@ class Settings(BaseSettings): jwt_algorithm: str = "HS256" access_token_expire_minutes: int = 30 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: env_file = str(Path(__file__).parent.parent / ".env") diff --git a/backend/app/main.py b/backend/app/main.py index aabb977..a511023 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -21,6 +21,38 @@ uploads_dir.mkdir(exist_ok=True) # Create tables 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( title="E-commerce API", description="Full-featured e-commerce API for clothing and shoes", diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 2d0c403..77ae775 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -25,6 +25,9 @@ class User(Base): country = Column(String, nullable=True) is_active = Column(Boolean, default=True) 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) cart = relationship("Cart", back_populates="user", uselist=False) diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 8d44ee3..c1556ff 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -2,14 +2,18 @@ from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.orm import Session from datetime import timedelta, datetime from pydantic import BaseModel, EmailStr +import random +import string from app.database.database import get_db 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 ( authenticate_user, create_access_token, get_password_hash, verify_token, + verify_password, + get_current_user, ) from app.config import settings @@ -130,3 +134,97 @@ def reset_password(request: ResetPasswordRequest, db: Session = Depends(get_db)) db.commit() 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"} diff --git a/backend/app/routers/users.py b/backend/app/routers/users.py index 8dd0bc4..31caa54 100644 --- a/backend/app/routers/users.py +++ b/backend/app/routers/users.py @@ -22,6 +22,16 @@ def update_user_profile( 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) + + # 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(): setattr(current_user, field, value) @@ -29,14 +39,6 @@ def update_user_profile( db.refresh(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) def get_user_by_id(user_id: int, db: Session = Depends(get_db)): diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index 1ddc7f5..fc38c43 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -19,6 +19,22 @@ class UserUpdate(BaseModel): city: Optional[str] = None postal_code: 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): @@ -30,6 +46,7 @@ class UserResponse(UserBase): country: Optional[str] is_active: bool is_admin: bool + must_change_password: bool = False created_at: datetime class Config: diff --git a/backend/migrations/005_add_password_reset_fields.sql b/backend/migrations/005_add_password_reset_fields.sql new file mode 100644 index 0000000..09a0a1c --- /dev/null +++ b/backend/migrations/005_add_password_reset_fields.sql @@ -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; diff --git a/brand-master-chart/templates/backend-deployment.yaml b/brand-master-chart/templates/backend-deployment.yaml index bb76006..28714ac 100644 --- a/brand-master-chart/templates/backend-deployment.yaml +++ b/brand-master-chart/templates/backend-deployment.yaml @@ -30,7 +30,7 @@ spec: {{- toYaml .Values.podSecurityContext | nindent 8 }} initContainers: - 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;'] containers: - name: backend diff --git a/brand-master-chart/values.yaml b/brand-master-chart/values.yaml index 7525c6e..b97cf95 100644 --- a/brand-master-chart/values.yaml +++ b/brand-master-chart/values.yaml @@ -28,6 +28,16 @@ backend: PYTHONUNBUFFERED: "1" BACKEND_URL: "https://api-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!) jwtSecretKey: "your-secret-key-change-this-in-production" diff --git a/frontend/src/pages/Login.jsx b/frontend/src/pages/Login.jsx index 92285fc..e2b20ad 100644 --- a/frontend/src/pages/Login.jsx +++ b/frontend/src/pages/Login.jsx @@ -16,8 +16,13 @@ export default function Login() { const [loading, setLoading] = useState(false) const [toast, setToast] = useState(null) const [showForgotPassword, setShowForgotPassword] = useState(false) + const [showPinEntry, setShowPinEntry] = useState(false) + const [showChangePassword, setShowChangePassword] = useState(false) const [resetEmail, setResetEmail] = useState('') + const [resetPin, setResetPin] = useState('') + const [newPassword, setNewPassword] = useState('') const [resetLoading, setResetLoading] = useState(false) + const [currentUserData, setCurrentUserData] = useState(null) const handleChange = (e) => { setFormData({ @@ -38,10 +43,17 @@ export default function Login() { }, }) - setToken(response.data.access_token) - setUser(response.data.user) - setToast({ type: 'success', message: 'Login successful!' }) - setTimeout(() => navigate('/'), 1000) + // Check if user must change password + if (response.data.user.must_change_password) { + setCurrentUserData({ token: response.data.access_token, user: response.data.user }) + 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) { setToast({ type: 'error', message: 'Invalid email or password' }) console.error('Login error:', error) @@ -55,18 +67,76 @@ export default function Login() { setResetLoading(true) try { - await api.post('/auth/forgot-password', { email: resetEmail }) - setToast({ type: 'success', message: 'Password reset link sent to your email!' }) + const response = await api.post('/auth/request-reset-pin', { email: resetEmail }) + setToast({ type: 'success', message: 'A PIN has been sent to your email!' }) 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) { - 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) } finally { 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 (
Don't have an account? Sign up here
- -Demo Accounts:
-- Admin: admin@example.com / password123 -
-User: user@example.com / password123
-