#!/usr/bin/env python3 """Interactive RollCalc user management.""" import json import os import stat import sys from getpass import getpass from pathlib import Path from werkzeug.security import generate_password_hash ROOT_DIR = Path(__file__).resolve().parents[1] USER_FILE = ROOT_DIR / "config" / "users.json" HASH_METHOD = "pbkdf2:sha256:600000" class UserManagementError(Exception): """Expected user-management error.""" def ensure_user_file(path=USER_FILE): """Create an empty user file if needed.""" path = Path(path) if path.exists(): return path.parent.mkdir(parents=True, exist_ok=True) path.write_text("{}\n", encoding="utf-8") set_restrictive_permissions(path) def set_restrictive_permissions(path): """Set user-only read/write permissions where supported.""" try: os.chmod(path, stat.S_IRUSR | stat.S_IWUSR) except OSError as exc: print(f"Warning: could not set restrictive permissions: {exc}") def load_users(path=USER_FILE): """Load user hashes from JSON.""" path = Path(path) ensure_user_file(path) try: data = json.loads(path.read_text(encoding="utf-8")) except json.JSONDecodeError as exc: raise UserManagementError(f"Invalid JSON in {path}: {exc}") from exc except OSError as exc: raise UserManagementError(f"Could not read {path}: {exc}") from exc if not isinstance(data, dict): raise UserManagementError(f"Invalid user file format in {path}: expected object") users = {} for username, password_hash in data.items(): if not isinstance(username, str) or not username.strip(): raise UserManagementError(f"Invalid username in {path}") if not isinstance(password_hash, str) or not password_hash.strip(): raise UserManagementError(f"Invalid password hash for user {username!r}") users[username.strip()] = password_hash.strip() return users def save_users(users, path=USER_FILE): """Persist user hashes to JSON.""" path = Path(path) path.parent.mkdir(parents=True, exist_ok=True) existing_mode = None if path.exists(): try: existing_mode = stat.S_IMODE(path.stat().st_mode) except OSError: existing_mode = None temp_path = path.with_name(f".{path.name}.tmp") try: temp_path.write_text( json.dumps(users, indent=2, sort_keys=True) + "\n", encoding="utf-8" ) if existing_mode is not None: os.chmod(temp_path, existing_mode) else: os.chmod(temp_path, stat.S_IRUSR | stat.S_IWUSR) os.replace(temp_path, path) except OSError as exc: try: temp_path.unlink() except OSError: pass raise UserManagementError(f"Could not write {path}: {exc}") from exc if existing_mode is None: set_restrictive_permissions(path) def hash_password(password): """Hash a password with RollCalc's established hash method.""" return generate_password_hash(password, method=HASH_METHOD) def add_user(users, username, password): """Add a user to a user dictionary.""" username = username.strip() if not username: raise UserManagementError("Username must not be empty.") if username in users: raise UserManagementError(f'User "{username}" already exists.') users[username] = hash_password(password) def update_password(users, username, password): """Update an existing user's password hash.""" username = username.strip() if username not in users: raise UserManagementError(f'User "{username}" does not exist.') users[username] = hash_password(password) def remove_user(users, username): """Remove an existing user.""" username = username.strip() if username not in users: raise UserManagementError(f'User "{username}" does not exist.') del users[username] def prompt_password_pair(): """Read and validate a repeated password.""" password = getpass("Password: ") repeat = getpass("Repeat password: ") if password != repeat: raise UserManagementError("Passwords do not match.") if not password: raise UserManagementError("Password must not be empty.") return password def list_users(users): """Print existing usernames without hashes.""" print("\nExisting users\n") if not users: print("(none)") else: for username in sorted(users): print(f"- {username}") print() def create_user_interactive(users): """Create a user from prompts.""" username = input("Username: ").strip() password = prompt_password_pair() add_user(users, username, password) print(f'User "{username}" created.') def change_password_interactive(users): """Change a user's password from prompts.""" username = input("Username: ").strip() password = prompt_password_pair() update_password(users, username, password) print(f'Password for "{username}" changed.') def delete_user_interactive(users): """Delete a user after confirmation.""" username = input("Username: ").strip() if username not in users: raise UserManagementError(f'User "{username}" does not exist.') confirmation = input(f'Delete user "{username}"? yes/no: ').strip().lower() if confirmation != "yes": print("Delete cancelled.") return remove_user(users, username) print(f'User "{username}" deleted.') def print_menu(): """Print the main menu.""" print( "\n" "--------------------------------------------------\n\n" "RollCalc User Management\n\n" "1 - List users\n" "2 - Create user\n" "3 - Change password\n" "4 - Delete user\n" "5 - Exit\n\n" "--------------------------------------------------" ) def run_menu(path=USER_FILE): """Run the interactive menu.""" while True: try: users = load_users(path) except UserManagementError as exc: print(f"Error: {exc}", file=sys.stderr) return 1 print_menu() choice = input("Select option: ").strip() try: if choice == "1": list_users(users) continue if choice == "2": create_user_interactive(users) elif choice == "3": change_password_interactive(users) elif choice == "4": delete_user_interactive(users) elif choice == "5": print("Exit.") return 0 else: print("Unknown option.") continue save_users(users, path) except UserManagementError as exc: print(f"Error: {exc}") def main(): """CLI entry point.""" return run_menu() if __name__ == "__main__": raise SystemExit(main())