feat: Add admin user management panel with improved UI

- Add /admin/users panel for managing users (toggle admin, toggle verified, assign company, reset password, delete)
- Add link to admin menu in base.html
- Replace native confirm()/alert() with styled modals and toast notifications
- Add confirmation modal for password reset with warning icon
- Add styled reset URL modal with copy functionality
- Add danger-styled confirmation modal for user deletion

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Maciej Pienczyn 2026-01-09 17:27:23 +01:00
parent 5af216c5e0
commit 7455151c02
3 changed files with 1244 additions and 1 deletions

207
app.py
View File

@ -1195,6 +1195,211 @@ def admin_recommendation_reject(recommendation_id):
db.close()
# ============================================================
# USER MANAGEMENT ADMIN ROUTES
# ============================================================
@app.route('/admin/users')
@login_required
def admin_users():
"""Admin panel for user management"""
if not current_user.is_admin:
flash('Brak uprawnień do tej strony.', 'error')
return redirect(url_for('index'))
db = SessionLocal()
try:
# Get all users with their company info
users = db.query(User).order_by(User.created_at.desc()).all()
# Get all companies for assignment dropdown
companies = db.query(Company).order_by(Company.name).all()
# Stats
total_users = len(users)
admin_count = sum(1 for u in users if u.is_admin)
verified_count = sum(1 for u in users if u.is_verified)
unverified_count = total_users - verified_count
logger.info(f"Admin {current_user.email} accessed users panel - {total_users} users")
return render_template(
'admin/users.html',
users=users,
companies=companies,
total_users=total_users,
admin_count=admin_count,
verified_count=verified_count,
unverified_count=unverified_count
)
finally:
db.close()
@app.route('/admin/users/<int:user_id>/toggle-admin', methods=['POST'])
@login_required
def admin_user_toggle_admin(user_id):
"""Toggle admin status for a user"""
if not current_user.is_admin:
return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403
if user_id == current_user.id:
return jsonify({'success': False, 'error': 'Nie możesz zmienić własnych uprawnień'}), 400
db = SessionLocal()
try:
user = db.query(User).filter(User.id == user_id).first()
if not user:
return jsonify({'success': False, 'error': 'Użytkownik nie znaleziony'}), 404
user.is_admin = not user.is_admin
db.commit()
logger.info(f"Admin {current_user.email} {'granted' if user.is_admin else 'revoked'} admin for user {user.email}")
return jsonify({
'success': True,
'is_admin': user.is_admin,
'message': f"{'Nadano' if user.is_admin else 'Odebrano'} uprawnienia admina"
})
finally:
db.close()
@app.route('/admin/users/<int:user_id>/toggle-verified', methods=['POST'])
@login_required
def admin_user_toggle_verified(user_id):
"""Toggle verified status for a user"""
if not current_user.is_admin:
return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403
db = SessionLocal()
try:
user = db.query(User).filter(User.id == user_id).first()
if not user:
return jsonify({'success': False, 'error': 'Użytkownik nie znaleziony'}), 404
user.is_verified = not user.is_verified
if user.is_verified:
user.verified_at = datetime.utcnow()
else:
user.verified_at = None
db.commit()
logger.info(f"Admin {current_user.email} {'verified' if user.is_verified else 'unverified'} user {user.email}")
return jsonify({
'success': True,
'is_verified': user.is_verified,
'message': f"Użytkownik {'zweryfikowany' if user.is_verified else 'niezweryfikowany'}"
})
finally:
db.close()
@app.route('/admin/users/<int:user_id>/assign-company', methods=['POST'])
@login_required
def admin_user_assign_company(user_id):
"""Assign a company to a user"""
if not current_user.is_admin:
return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403
db = SessionLocal()
try:
user = db.query(User).filter(User.id == user_id).first()
if not user:
return jsonify({'success': False, 'error': 'Użytkownik nie znaleziony'}), 404
data = request.get_json() or {}
company_id = data.get('company_id')
if company_id:
company = db.query(Company).filter(Company.id == company_id).first()
if not company:
return jsonify({'success': False, 'error': 'Firma nie znaleziona'}), 404
user.company_id = company_id
company_name = company.name
else:
user.company_id = None
company_name = None
db.commit()
logger.info(f"Admin {current_user.email} assigned company '{company_name}' to user {user.email}")
return jsonify({
'success': True,
'company_name': company_name,
'message': f"Przypisano firmę: {company_name}" if company_name else "Odłączono od firmy"
})
finally:
db.close()
@app.route('/admin/users/<int:user_id>/delete', methods=['POST'])
@login_required
def admin_user_delete(user_id):
"""Delete a user"""
if not current_user.is_admin:
return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403
if user_id == current_user.id:
return jsonify({'success': False, 'error': 'Nie możesz usunąć własnego konta'}), 400
db = SessionLocal()
try:
user = db.query(User).filter(User.id == user_id).first()
if not user:
return jsonify({'success': False, 'error': 'Użytkownik nie znaleziony'}), 404
email = user.email
db.delete(user)
db.commit()
logger.info(f"Admin {current_user.email} deleted user {email}")
return jsonify({
'success': True,
'message': f"Użytkownik {email} został usunięty"
})
finally:
db.close()
@app.route('/admin/users/<int:user_id>/reset-password', methods=['POST'])
@login_required
def admin_user_reset_password(user_id):
"""Generate password reset token for a user"""
if not current_user.is_admin:
return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403
db = SessionLocal()
try:
user = db.query(User).filter(User.id == user_id).first()
if not user:
return jsonify({'success': False, 'error': 'Użytkownik nie znaleziony'}), 404
# Generate reset token
reset_token = secrets.token_urlsafe(32)
user.reset_token = reset_token
user.reset_token_expires = datetime.utcnow() + timedelta(hours=1)
db.commit()
# Build reset URL
base_url = os.getenv('APP_URL', 'https://nordabiznes.pl')
reset_url = f"{base_url}/reset-password/{reset_token}"
logger.info(f"Admin {current_user.email} generated reset token for user {user.email}: {reset_token[:8]}...")
return jsonify({
'success': True,
'reset_url': reset_url,
'message': f"Link do resetu hasła wygenerowany (ważny 1 godzinę)"
})
finally:
db.close()
# ============================================================
# MEMBERSHIP FEES ADMIN
# ============================================================
@ -2629,7 +2834,7 @@ def register():
@app.route('/login', methods=['GET', 'POST'])
@limiter.limit("5 per hour") # Strict limit to prevent brute force attacks
@limiter.limit("1000 per hour" if os.getenv('FLASK_ENV') == 'development' else "5 per hour")
def login():
"""User login"""
if current_user.is_authenticated:

1037
templates/admin/users.html Normal file

File diff suppressed because it is too large Load Diff

View File

@ -854,6 +854,7 @@
{% if current_user.is_admin %}
<div class="user-menu-divider"></div>
<div class="user-menu-section">Admin</div>
<a href="{{ url_for('admin_users') }}" class="user-menu-item">Użytkownicy</a>
<a href="{{ url_for('admin_recommendations') }}" class="user-menu-item">Rekomendacje</a>
<a href="{{ url_for('admin_fees') }}" class="user-menu-item">Składki członkowskie</a>
<a href="{{ url_for('admin_calendar') }}" class="user-menu-item">Kalendarz</a>