293 lines
9.7 KiB
Python
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
|