feat: Add admin user, PIN-based password reset, and profile management
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:
dvirlabs 2026-05-07 08:09:30 +03:00
parent 548711b68d
commit 417b2ef877
11 changed files with 426 additions and 34 deletions

View File

@ -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")

View File

@ -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",

View File

@ -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)

View File

@ -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"}

View File

@ -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)):

View File

@ -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:

View 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;

View File

@ -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

View File

@ -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"

View File

@ -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 (
<div className="auth-page">
<div className="auth-container">
@ -113,16 +183,9 @@ export default function Login() {
<p className="auth-switch">
Don't have an account? <Link to="/register">Sign up here</Link>
</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>
{/* Forgot Password Modal */}
<Modal
isOpen={showForgotPassword}
onClose={() => setShowForgotPassword(false)}
@ -130,7 +193,7 @@ export default function Login() {
>
<form onSubmit={handleForgotPassword}>
<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>
<div className="form-group">
<label>Email</label>
@ -143,7 +206,71 @@ export default function Login() {
/>
</div>
<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>
</form>
</Modal>

View File

@ -10,14 +10,21 @@ export default function Profile() {
const { token, user, setUser } = useContext(AuthContext)
const [formData, setFormData] = useState({
full_name: '',
email: '',
phone: '',
address: '',
city: '',
postal_code: '',
country: '',
})
const [passwordData, setPasswordData] = useState({
old_password: '',
new_password: '',
confirm_password: '',
})
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [changingPassword, setChangingPassword] = useState(false)
const [toast, setToast] = useState(null)
useEffect(() => {
@ -52,19 +59,46 @@ export default function Profile() {
setSaving(true)
try {
const response = await api.put('/users/me', formData, {
params: { token },
})
const response = await api.put('/users/me', formData)
setUser(response.data)
setToast({ type: 'success', message: 'Profile updated successfully!' })
} catch (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 {
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>
return (
@ -100,8 +134,14 @@ export default function Profile() {
</div>
<div className="form-group">
<label>Email (Read-only)</label>
<input type="email" value={formData.email} disabled />
<label>Email</label>
<input
type="email"
name="email"
value={formData.email}
onChange={handleChange}
required
/>
</div>
<div className="form-group">
@ -162,6 +202,46 @@ export default function Profile() {
{saving ? 'Saving...' : 'Save Changes'}
</button>
</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>
{toast && (