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

@ -9,6 +9,18 @@ class Settings(BaseSettings):
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")

View File

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

View File

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

View File

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

View File

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

View File

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

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 }} {{- 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

View File

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

View File

@ -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() {
}, },
}) })
// 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) setToken(response.data.access_token)
setUser(response.data.user) setUser(response.data.user)
setToast({ type: 'success', message: 'Login successful!' }) setToast({ type: 'success', message: 'Login successful!' })
setTimeout(() => navigate('/'), 1000) 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>

View File

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