"""Router users — gestion utilisateurs par profil + picker de contacts iTop. Seul l'admin peut gérer les utilisateurs. Les utilisateurs sont créés depuis les contacts synchronisés d'iTop (sauf admin local). """ from fastapi import APIRouter, Request, Depends, Form from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.templating import Jinja2Templates from sqlalchemy import text from ..dependencies import get_db, get_current_user, get_user_perms, can_view, can_edit, can_admin, base_context from ..auth import hash_password from ..services.profile_service import PROFILES, PROFILE_LABELS, PROFILE_DESCRIPTIONS from ..config import APP_NAME router = APIRouter() templates = Jinja2Templates(directory="app/templates") # Profils disponibles (keys du PROFILES dict) ROLES = ["admin", "coordinator", "operator", "viewer"] def _get_users(db): users = db.execute(text(""" SELECT u.id, u.username, u.display_name, u.email, u.role, u.auth_type, u.is_active, u.last_login, u.itop_person_id, u.force_password_change, c.team as contact_team, c.function as contact_function FROM users u LEFT JOIN contacts c ON c.itop_id = u.itop_person_id ORDER BY u.is_active DESC, u.username """)).fetchall() return users def _check_access(request, db, require_edit=False): user = get_current_user(request) if not user: return None, None, RedirectResponse(url="/login") perms = get_user_perms(db, user) if not can_view(perms, "users"): return None, None, RedirectResponse(url="/dashboard") if require_edit and not can_edit(perms, "users"): return None, None, RedirectResponse(url="/users?msg=forbidden", status_code=303) return user, perms, None @router.get("/me/change-password", response_class=HTMLResponse) async def me_change_password_page(request: Request, db=Depends(get_db)): user = get_current_user(request) if not user: return RedirectResponse(url="/login") return templates.TemplateResponse("change_password.html", { "request": request, "app_name": APP_NAME, "user": user, "error": None, "perms": {}, }) @router.post("/me/change-password") async def me_change_password(request: Request, db=Depends(get_db), current_password: str = Form(...), new_password: str = Form(...), confirm_password: str = Form(...)): user = get_current_user(request) if not user: return RedirectResponse(url="/login") from ..auth import verify_password row = db.execute(text("SELECT id, password_hash FROM users WHERE id=:uid"), {"uid": user.get("uid")}).fetchone() if not row: return RedirectResponse(url="/logout", status_code=303) err = None if not verify_password(current_password, row.password_hash or ""): err = "Mot de passe actuel incorrect" elif new_password != confirm_password: err = "Les deux mots de passe ne correspondent pas" elif len(new_password) < 8: err = "Le nouveau mot de passe doit faire au moins 8 caractères" elif new_password == current_password: err = "Le nouveau mot de passe doit être différent de l'actuel" if err: return templates.TemplateResponse("change_password.html", { "request": request, "app_name": APP_NAME, "user": user, "error": err, "perms": {}, }) pw_hash = hash_password(new_password) db.execute(text("""UPDATE users SET password_hash=:ph, force_password_change=false, updated_at=NOW() WHERE id=:uid"""), {"ph": pw_hash, "uid": user.get("uid")}) db.commit() return RedirectResponse(url="/dashboard?msg=password_changed", status_code=303) @router.get("/users", response_class=HTMLResponse) async def users_page(request: Request, db=Depends(get_db)): user, perms, redirect = _check_access(request, db) if redirect: return redirect users = _get_users(db) # Compter les contacts disponibles pour ajout (pas encore users) available_count = db.execute(text(""" SELECT COUNT(*) FROM contacts c WHERE c.itop_id IS NOT NULL AND c.is_active = true AND NOT EXISTS (SELECT 1 FROM users u WHERE u.itop_person_id = c.itop_id) """)).scalar() ctx = base_context(request, db, user) ctx.update({ "app_name": APP_NAME, "users": users, "available_count": available_count, "roles": ROLES, "profile_labels": PROFILE_LABELS, "profile_descriptions": PROFILE_DESCRIPTIONS, "can_edit_users": can_edit(perms, "users"), "can_admin_users": can_admin(perms, "users"), "msg": request.query_params.get("msg"), }) return templates.TemplateResponse("users.html", ctx) @router.get("/users/add", response_class=HTMLResponse) async def users_add_page(request: Request, db=Depends(get_db), search: str = "", team: str = ""): user, perms, redirect = _check_access(request, db, require_edit=True) if redirect: return redirect # Lister les contacts iTop non-encore-users where = ["c.itop_id IS NOT NULL", "c.is_active = true", "NOT EXISTS (SELECT 1 FROM users u WHERE u.itop_person_id = c.itop_id)"] params = {} if search: where.append("(c.name ILIKE :s OR c.email ILIKE :s)") params["s"] = f"%{search}%" if team: where.append("c.team = :t") params["t"] = team wc = " AND ".join(where) contacts = db.execute(text(f""" SELECT c.id, c.itop_id, c.name, c.email, c.telephone, c.team, c.function, c.role FROM contacts c WHERE {wc} ORDER BY c.team, c.name LIMIT 200 """), params).fetchall() # Liste des teams disponibles pour filtrer teams = db.execute(text(""" SELECT DISTINCT team FROM contacts WHERE team IS NOT NULL AND team != '' ORDER BY team """)).fetchall() ctx = base_context(request, db, user) ctx.update({ "app_name": APP_NAME, "contacts": contacts, "teams": teams, "roles": ROLES, "profile_labels": PROFILE_LABELS, "profile_descriptions": PROFILE_DESCRIPTIONS, "search": search, "team_filter": team, }) return templates.TemplateResponse("users_add.html", ctx) @router.post("/users/add") async def users_add(request: Request, db=Depends(get_db), contact_id: str = Form(""), role: str = Form("viewer"), username: str = Form(""), password: str = Form(""), auth_type: str = Form("local"), force_change: str = Form("")): user, perms, redirect = _check_access(request, db, require_edit=True) if redirect: return redirect if role not in ROLES: return RedirectResponse(url="/users?msg=invalid_role", status_code=303) # Récupérer le contact iTop contact = None if contact_id and contact_id.strip().isdigit(): contact = db.execute(text( "SELECT id, itop_id, name, email FROM contacts WHERE id = :cid" ), {"cid": int(contact_id)}).fetchone() if not contact: return RedirectResponse(url="/users/add?msg=contact_required", status_code=303) # Générer username si non fourni (email avant @) if not username.strip(): username = contact.email.split("@")[0] if contact.email else contact.name.lower().replace(" ", ".") # Verifier duplicats existing = db.execute(text("SELECT id FROM users WHERE LOWER(username)=LOWER(:u) OR itop_person_id=:iid"), {"u": username.strip(), "iid": contact.itop_id}).fetchone() if existing: return RedirectResponse(url="/users?msg=exists", status_code=303) pw_hash = hash_password(password) if password else hash_password("ChangeMe!" + str(contact.itop_id)) fpc = True if force_change == "on" else (not password) db.execute(text(""" INSERT INTO users (username, display_name, email, password_hash, role, auth_type, itop_person_id, is_active, force_password_change) VALUES (:u, :dn, :e, :ph, :r, :at, :iid, true, :fpc) """), { "u": username.strip(), "dn": contact.name, "e": contact.email, "ph": pw_hash, "r": role, "at": auth_type, "iid": contact.itop_id, "fpc": fpc, }) db.commit() return RedirectResponse(url="/users?msg=added", status_code=303) @router.post("/users/{user_id}/role") async def user_change_role(request: Request, user_id: int, db=Depends(get_db), role: str = Form(...)): user, perms, redirect = _check_access(request, db, require_edit=True) if redirect: return redirect if role not in ROLES: return RedirectResponse(url="/users?msg=invalid_role", status_code=303) # Empêche un admin de se rétrograder lui-même if user_id == user.get("uid") and role != "admin": return RedirectResponse(url="/users?msg=cant_demote_self", status_code=303) db.execute(text("UPDATE users SET role=:r, updated_at=NOW() WHERE id=:id"), {"r": role, "id": user_id}) db.commit() return RedirectResponse(url="/users?msg=role_changed", status_code=303) @router.post("/users/{user_id}/toggle") async def user_toggle(request: Request, user_id: int, db=Depends(get_db)): user, perms, redirect = _check_access(request, db, require_edit=True) if redirect: return redirect if user_id == user.get("uid"): return RedirectResponse(url="/users?msg=cant_self", status_code=303) db.execute(text("UPDATE users SET is_active = NOT is_active WHERE id=:id"), {"id": user_id}) db.commit() return RedirectResponse(url="/users?msg=toggled", status_code=303) @router.post("/users/{user_id}/password") async def user_password(request: Request, user_id: int, db=Depends(get_db), new_password: str = Form(...), force_change: str = Form("")): user, perms, redirect = _check_access(request, db, require_edit=True) if redirect: return redirect pw_hash = hash_password(new_password) fpc = (force_change == "on") db.execute(text("""UPDATE users SET password_hash=:ph, force_password_change=:fpc, updated_at=NOW() WHERE id=:id"""), {"ph": pw_hash, "fpc": fpc, "id": user_id}) db.commit() return RedirectResponse(url="/users?msg=password_changed", status_code=303) @router.post("/users/{user_id}/auth_type") async def user_auth_type(request: Request, user_id: int, db=Depends(get_db), auth_type: str = Form(...)): user, perms, redirect = _check_access(request, db, require_edit=True) if redirect: return redirect if auth_type not in ("local", "ldap"): return RedirectResponse(url="/users?msg=invalid", status_code=303) db.execute(text("UPDATE users SET auth_type=:at, updated_at=NOW() WHERE id=:id"), {"at": auth_type, "id": user_id}) db.commit() return RedirectResponse(url="/users?msg=auth_changed", status_code=303) @router.post("/users/{user_id}/delete") async def user_delete(request: Request, user_id: int, db=Depends(get_db)): user, perms, redirect = _check_access(request, db) if redirect: return redirect if not can_admin(perms, "users"): return RedirectResponse(url="/users?msg=forbidden", status_code=303) if user_id == user.get("uid"): return RedirectResponse(url="/users?msg=cant_self", status_code=303) # Protéger l'admin local (id=1) row = db.execute(text("SELECT username, auth_type FROM users WHERE id=:id"), {"id": user_id}).fetchone() if row and row.username == "admin" and row.auth_type == "local": return RedirectResponse(url="/users?msg=cant_delete_admin", status_code=303) db.execute(text("DELETE FROM user_permissions WHERE user_id=:uid"), {"uid": user_id}) db.execute(text("DELETE FROM users WHERE id=:id"), {"id": user_id}) db.commit() return RedirectResponse(url="/users?msg=deleted", status_code=303)