Files

293 lines
9.7 KiB
Python

"""Cockpit user accounts: password hashing, CRUD, permissions."""
from __future__ import annotations
import hashlib
import json
import secrets
from typing import Any, Optional
from app.db import execute, fetch_all, fetch_one
# Nav / feature keys shown in Settings → Gebruikers
PAGE_CATALOG: list[dict[str, str]] = [
{"key": "dashboard", "label": "Overzicht (CEO)"},
{"key": "revenue", "label": "Revenue Cockpit"},
{"key": "retail", "label": "Retail NL"},
{"key": "marketing", "label": "Marketing"},
{"key": "packaging", "label": "Packaging"},
{"key": "beurs", "label": "Beurs Live"},
{"key": "documents", "label": "Documenten"},
{"key": "projects", "label": "Projecten"},
{"key": "hermes", "label": "Telegram / Hermes"},
{"key": "analytics", "label": "Analytics"},
{"key": "export", "label": "Wereldexport"},
{"key": "clients", "label": "Klanten"},
{"key": "deals", "label": "Deals"},
{"key": "products", "label": "Producten"},
{"key": "suppliers", "label": "Leveranciers"},
{"key": "reports", "label": "Rapporten"},
{"key": "ops", "label": "IT Ops"},
{"key": "agents", "label": "Agents"},
{"key": "browser", "label": "Browser"},
{"key": "studio", "label": "Studio"},
{"key": "voice", "label": "Voice"},
{"key": "settings", "label": "Instellingen"},
]
# path prefix → permission key (order: longest prefix first)
PATH_RULES: list[tuple[str, str]] = [
("/revenue-cockpit", "revenue"),
("/export-intel", "export"),
("/packaging", "packaging"),
("/marketing", "marketing"),
("/documents", "documents"),
("/analytics", "analytics"),
("/suppliers", "suppliers"),
("/products", "products"),
("/projects", "projects"),
("/settings", "settings"),
("/clients", "clients"),
("/browser", "browser"),
("/monitor", "browser"),
("/hermes", "hermes"),
("/retail", "retail"),
("/reports", "reports"),
("/studio", "studio"),
("/agents", "agents"),
("/beurs", "beurs"),
("/deals", "deals"),
("/voice", "voice"),
("/ops", "ops"),
("/", "dashboard"),
]
ADMIN_SEED_USERNAME = "aissa"
ADMIN_SEED_PASSWORD = "Foodlinkk#2026"
def hash_password(password: str) -> str:
salt = secrets.token_hex(16)
rounds = 200_000
dk = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt.encode("utf-8"), rounds)
return f"pbkdf2_sha256${rounds}${salt}${dk.hex()}"
def verify_password(password: str, password_hash: str) -> bool:
try:
algo, rounds_s, salt, digest = password_hash.split("$", 3)
if algo != "pbkdf2_sha256":
return False
rounds = int(rounds_s)
dk = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt.encode("utf-8"), rounds)
return secrets.compare_digest(dk.hex(), digest)
except Exception:
return False
def _normalize_perms(raw: Any) -> dict[str, Any]:
if raw is None:
return {"pages": [], "manage_users": False}
if isinstance(raw, str):
try:
raw = json.loads(raw)
except Exception:
return {"pages": [], "manage_users": False}
if not isinstance(raw, dict):
return {"pages": [], "manage_users": False}
pages = raw.get("pages") or []
if not isinstance(pages, list):
pages = []
return {
"pages": [str(p) for p in pages],
"manage_users": bool(raw.get("manage_users")),
}
def public_user(row: dict[str, Any] | None) -> dict[str, Any] | None:
if not row:
return None
perms = _normalize_perms(row.get("permissions"))
role = row.get("role") or "user"
if role == "admin":
perms = {
"pages": [p["key"] for p in PAGE_CATALOG],
"manage_users": True,
}
return {
"id": row["id"],
"username": row["username"],
"display_name": row.get("display_name") or row["username"],
"role": role,
"is_active": bool(row.get("is_active", True)),
"avatar_url": row.get("avatar_url") or None,
"permissions": perms,
"created_at": row["created_at"].isoformat() if row.get("created_at") else None,
"last_login_at": row["last_login_at"].isoformat() if row.get("last_login_at") else None,
}
def ensure_admin_seed() -> None:
"""Create default admin aissa if table empty / user missing."""
row = fetch_one("SELECT id FROM cockpit_users WHERE username = %s", (ADMIN_SEED_USERNAME,))
if row:
return
execute(
"""
INSERT INTO cockpit_users (username, password_hash, display_name, role, permissions)
VALUES (%s, %s, %s, 'admin', %s::jsonb)
ON CONFLICT (username) DO NOTHING
""",
(
ADMIN_SEED_USERNAME,
hash_password(ADMIN_SEED_PASSWORD),
"Aissa",
json.dumps({"pages": [p["key"] for p in PAGE_CATALOG], "manage_users": True}),
),
)
def authenticate(username: str, password: str) -> Optional[dict[str, Any]]:
row = fetch_one(
"SELECT * FROM cockpit_users WHERE lower(username) = lower(%s)",
(username.strip(),),
)
if not row or not row.get("is_active"):
return None
if not verify_password(password, row["password_hash"]):
return None
execute(
"UPDATE cockpit_users SET last_login_at = NOW(), updated_at = NOW() WHERE id = %s",
(row["id"],),
)
return public_user(row)
def get_user_by_id(user_id: int) -> Optional[dict[str, Any]]:
row = fetch_one("SELECT * FROM cockpit_users WHERE id = %s", (user_id,))
return public_user(row)
def list_users() -> list[dict[str, Any]]:
rows = fetch_all("SELECT * FROM cockpit_users ORDER BY username ASC")
return [public_user(r) for r in rows if r]
def create_user(
username: str,
password: str,
display_name: str | None,
role: str,
permissions: dict[str, Any],
) -> dict[str, Any]:
username = username.strip()
if not username or not password:
raise ValueError("Gebruikersnaam en wachtwoord zijn verplicht")
if role not in ("admin", "user"):
role = "user"
perms = _normalize_perms(permissions)
if role == "admin":
perms["manage_users"] = True
perms["pages"] = [p["key"] for p in PAGE_CATALOG]
row = fetch_one(
"""
INSERT INTO cockpit_users (username, password_hash, display_name, role, permissions)
VALUES (%s, %s, %s, %s, %s::jsonb)
RETURNING *
""",
(username, hash_password(password), (display_name or username).strip(), role, json.dumps(perms)),
)
return public_user(row) # type: ignore
def update_user(
user_id: int,
*,
display_name: str | None = None,
role: str | None = None,
is_active: bool | None = None,
permissions: dict[str, Any] | None = None,
password: str | None = None,
) -> dict[str, Any]:
row = fetch_one("SELECT * FROM cockpit_users WHERE id = %s", (user_id,))
if not row:
raise ValueError("Gebruiker niet gevonden")
new_name = display_name if display_name is not None else row.get("display_name")
new_role = role if role in ("admin", "user") else row["role"]
new_active = row["is_active"] if is_active is None else bool(is_active)
new_perms = _normalize_perms(permissions if permissions is not None else row.get("permissions"))
if new_role == "admin":
new_perms["manage_users"] = True
new_perms["pages"] = [p["key"] for p in PAGE_CATALOG]
pw_hash = row["password_hash"]
if password:
pw_hash = hash_password(password)
updated = fetch_one(
"""
UPDATE cockpit_users
SET display_name = %s, role = %s, is_active = %s, permissions = %s::jsonb,
password_hash = %s, updated_at = NOW()
WHERE id = %s
RETURNING *
""",
(new_name, new_role, new_active, json.dumps(new_perms), pw_hash, user_id),
)
return public_user(updated) # type: ignore
def delete_user(user_id: int, *, actor_id: int) -> None:
if user_id == actor_id:
raise ValueError("Je kunt jezelf niet verwijderen")
row = fetch_one("SELECT role FROM cockpit_users WHERE id = %s", (user_id,))
if not row:
raise ValueError("Gebruiker niet gevonden")
if row["role"] == "admin":
admins = fetch_one(
"SELECT COUNT(*)::int AS n FROM cockpit_users WHERE role = 'admin' AND is_active = TRUE"
)
if admins and admins["n"] <= 1:
raise ValueError("Laatste admin kan niet verwijderd worden")
execute("DELETE FROM cockpit_users WHERE id = %s", (user_id,))
def path_allowed(user: dict[str, Any] | None, path: str) -> bool:
if not user:
return False
if user.get("role") == "admin":
return True
pages = set((user.get("permissions") or {}).get("pages") or [])
# normalize
p = path.split("?")[0] or "/"
if p != "/" and p.endswith("/"):
p = p.rstrip("/")
for prefix, key in PATH_RULES:
if prefix == "/":
if p == "/" or p == "":
return "dashboard" in pages
continue
if p == prefix or p.startswith(prefix + "/"):
return key in pages
return "dashboard" in pages
def can_manage_users(user: dict[str, Any] | None) -> bool:
if not user:
return False
if user.get("role") == "admin":
return True
return bool((user.get("permissions") or {}).get("manage_users"))
def set_avatar(user_id: int, avatar_url: str | None) -> dict[str, Any]:
row = fetch_one(
"""
UPDATE cockpit_users
SET avatar_url = %s, updated_at = NOW()
WHERE id = %s
RETURNING *
""",
(avatar_url, user_id),
)
if not row:
raise ValueError("Gebruiker niet gevonden")
return public_user(row) # type: ignore