fix landing page

This commit is contained in:
Stefano Manfredi
2025-12-02 23:18:43 +00:00
parent 7168fa4b72
commit ce9e2fdf2a
17 changed files with 727 additions and 457 deletions

View File

@@ -2,17 +2,18 @@
User Management Routes
Admin user CRUD and user self-service (profile, settings, password)
"""
from typing import List
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, EmailStr
from sqlalchemy.orm import Session
import uuid
import re
from database.connection import get_db
from database.models import User
from utils.auth_middleware import get_current_user, require_admin
from utils.helpers import (
generate_uuid, is_ldap_user, is_ldap_admin,
validate_password, format_password_errors, get_notification_default
)
from services.auth import hash_password, verify_password
from app import config
@@ -73,23 +74,33 @@ class UserResponse(BaseModel):
from_attributes = True
def user_to_response(user: User, db: Session) -> dict:
"""Convert user to response dict with computed fields"""
# Get manager name if user has a manager
def user_to_response(user: User, db: Session, manager_lookup: dict = None, managed_counts: dict = None) -> dict:
"""
Convert user to response dict with computed fields.
Args:
user: The user to convert
db: Database session
manager_lookup: Optional pre-fetched dict of manager_id -> name (for batch operations)
managed_counts: Optional pre-fetched dict of user_id -> managed_user_count (for batch operations)
"""
# Get manager name - use lookup if available, otherwise query
manager_name = None
if user.manager_id:
manager = db.query(User).filter(User.id == user.manager_id).first()
if manager:
manager_name = manager.name
if manager_lookup is not None:
manager_name = manager_lookup.get(user.manager_id)
else:
manager = db.query(User).filter(User.id == user.manager_id).first()
if manager:
manager_name = manager.name
# Count managed users if this user is a manager
managed_user_count = None
if user.role == "manager":
managed_user_count = db.query(User).filter(User.manager_id == user.id).count()
# Determine if user is LDAP-managed
is_ldap_user = config.AUTHELIA_ENABLED and user.password_hash is None
is_ldap_admin = is_ldap_user and user.role == "admin"
if managed_counts is not None:
managed_user_count = managed_counts.get(user.id, 0)
else:
managed_user_count = db.query(User).filter(User.manager_id == user.id).count()
return {
"id": user.id,
@@ -101,8 +112,8 @@ def user_to_response(user: User, db: Session) -> dict:
"manager_parking_quota": user.manager_parking_quota,
"manager_spot_prefix": user.manager_spot_prefix,
"managed_user_count": managed_user_count,
"is_ldap_user": is_ldap_user,
"is_ldap_admin": is_ldap_admin,
"is_ldap_user": is_ldap_user(user),
"is_ldap_admin": is_ldap_admin(user),
"created_at": user.created_at
}
@@ -112,7 +123,25 @@ def user_to_response(user: User, db: Session) -> dict:
def list_users(db: Session = Depends(get_db), user=Depends(require_admin)):
"""List all users (admin only)"""
users = db.query(User).all()
return [user_to_response(u, db) for u in users]
# Build lookups to avoid N+1 queries
# Manager lookup: id -> name
manager_ids = list(set(u.manager_id for u in users if u.manager_id))
managers = db.query(User).filter(User.id.in_(manager_ids)).all() if manager_ids else []
manager_lookup = {m.id: m.name for m in managers}
# Managed user counts for managers
from sqlalchemy import func
manager_user_ids = [u.id for u in users if u.role == "manager"]
if manager_user_ids:
counts = db.query(User.manager_id, func.count(User.id)).filter(
User.manager_id.in_(manager_user_ids)
).group_by(User.manager_id).all()
managed_counts = {manager_id: count for manager_id, count in counts}
else:
managed_counts = {}
return [user_to_response(u, db, manager_lookup, managed_counts) for u in users]
@router.get("/{user_id}")
@@ -136,13 +165,18 @@ def create_user(data: UserCreate, db: Session = Depends(get_db), user=Depends(re
if data.role not in ["admin", "manager", "employee"]:
raise HTTPException(status_code=400, detail="Invalid role")
# Validate password strength
password_errors = validate_password(data.password)
if password_errors:
raise HTTPException(status_code=400, detail=format_password_errors(password_errors))
if data.manager_id:
manager = db.query(User).filter(User.id == data.manager_id).first()
if not manager or manager.role != "manager":
raise HTTPException(status_code=400, detail="Invalid manager")
new_user = User(
id=str(uuid.uuid4()),
id=generate_uuid(),
email=data.email,
password_hash=hash_password(data.password),
name=data.name,
@@ -154,6 +188,7 @@ def create_user(data: UserCreate, db: Session = Depends(get_db), user=Depends(re
db.add(new_user)
db.commit()
db.refresh(new_user)
config.logger.info(f"Admin created new user: {data.email}")
return user_to_response(new_user, db)
@@ -165,12 +200,12 @@ def update_user(user_id: str, data: UserUpdate, db: Session = Depends(get_db), u
raise HTTPException(status_code=404, detail="User not found")
# Check if user is LDAP-managed
is_ldap_user = config.AUTHELIA_ENABLED and target.password_hash is None
is_ldap_admin = is_ldap_user and target.role == "admin"
target_is_ldap = is_ldap_user(target)
target_is_ldap_admin = is_ldap_admin(target)
# Name update - blocked for LDAP users
if data.name is not None:
if is_ldap_user:
if target_is_ldap:
raise HTTPException(status_code=400, detail="Name is managed by LDAP")
target.name = data.name
@@ -179,7 +214,7 @@ def update_user(user_id: str, data: UserUpdate, db: Session = Depends(get_db), u
if data.role not in ["admin", "manager", "employee"]:
raise HTTPException(status_code=400, detail="Invalid role")
# Can't change admin role for LDAP admins (they get admin from parking_admins group)
if is_ldap_admin and data.role != "admin":
if target_is_ldap_admin and data.role != "admin":
raise HTTPException(status_code=400, detail="Admin role is managed by LDAP group (parking_admins)")
# If changing from manager to another role, check for managed users
if target.role == "manager" and data.role != "manager":
@@ -254,8 +289,6 @@ def delete_user(user_id: str, db: Session = Depends(get_db), current_user=Depend
@router.get("/me/profile")
def get_profile(db: Session = Depends(get_db), current_user=Depends(get_current_user)):
"""Get current user's profile"""
is_ldap_user = config.AUTHELIA_ENABLED and current_user.password_hash is None
# Get manager name
manager_name = None
if current_user.manager_id:
@@ -270,17 +303,15 @@ def get_profile(db: Session = Depends(get_db), current_user=Depends(get_current_
"role": current_user.role,
"manager_id": current_user.manager_id,
"manager_name": manager_name,
"is_ldap_user": is_ldap_user
"is_ldap_user": is_ldap_user(current_user)
}
@router.put("/me/profile")
def update_profile(data: ProfileUpdate, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
"""Update current user's profile (limited fields)"""
is_ldap_user = config.AUTHELIA_ENABLED and current_user.password_hash is None
if data.name is not None:
if is_ldap_user:
if is_ldap_user(current_user):
raise HTTPException(status_code=400, detail="Name is managed by LDAP")
current_user.name = data.name
current_user.updated_at = datetime.utcnow().isoformat()
@@ -293,12 +324,12 @@ def update_profile(data: ProfileUpdate, db: Session = Depends(get_db), current_u
def get_settings(current_user=Depends(get_current_user)):
"""Get current user's settings"""
return {
"week_start_day": current_user.week_start_day or 0,
"notify_weekly_parking": current_user.notify_weekly_parking if current_user.notify_weekly_parking is not None else 1,
"notify_daily_parking": current_user.notify_daily_parking if current_user.notify_daily_parking is not None else 1,
"notify_daily_parking_hour": current_user.notify_daily_parking_hour if current_user.notify_daily_parking_hour is not None else 8,
"notify_daily_parking_minute": current_user.notify_daily_parking_minute if current_user.notify_daily_parking_minute is not None else 0,
"notify_parking_changes": current_user.notify_parking_changes if current_user.notify_parking_changes is not None else 1
"week_start_day": get_notification_default(current_user.week_start_day, 0),
"notify_weekly_parking": get_notification_default(current_user.notify_weekly_parking, 1),
"notify_daily_parking": get_notification_default(current_user.notify_daily_parking, 1),
"notify_daily_parking_hour": get_notification_default(current_user.notify_daily_parking_hour, 8),
"notify_daily_parking_minute": get_notification_default(current_user.notify_daily_parking_minute, 0),
"notify_parking_changes": get_notification_default(current_user.notify_parking_changes, 1)
}
@@ -346,28 +377,19 @@ def update_settings(data: SettingsUpdate, db: Session = Depends(get_db), current
@router.post("/me/change-password")
def change_password(data: ChangePasswordRequest, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
"""Change current user's password (not available in LDAP mode)"""
if config.AUTHELIA_ENABLED and current_user.password_hash is None:
if is_ldap_user(current_user):
raise HTTPException(status_code=400, detail="Password is managed by LDAP")
if not verify_password(data.current_password, current_user.password_hash):
raise HTTPException(status_code=400, detail="Current password is incorrect")
# Validate new password
password = data.new_password
errors = []
if len(password) < 8:
errors.append("at least 8 characters")
if not re.search(r'[A-Z]', password):
errors.append("one uppercase letter")
if not re.search(r'[a-z]', password):
errors.append("one lowercase letter")
if not re.search(r'[0-9]', password):
errors.append("one number")
password_errors = validate_password(data.new_password)
if password_errors:
raise HTTPException(status_code=400, detail=format_password_errors(password_errors))
if errors:
raise HTTPException(status_code=400, detail=f"Password must contain: {', '.join(errors)}")
current_user.password_hash = hash_password(password)
current_user.password_hash = hash_password(data.new_password)
current_user.updated_at = datetime.utcnow().isoformat()
db.commit()
config.logger.info(f"User {current_user.email} changed password")
return {"message": "Password changed"}