""" views/admin_users_view.py — Admin panel: User Management tab. """ import tkinter as tk from tkinter import ttk import logging from utils.ui_helpers import ( COLOURS, FONT, FONT_BOLD, FONT_HEADING, FONT_SMALL, show_error, show_info, confirm_delete ) logger = logging.getLogger("admin_users_view") class AdminUsersView(ttk.Frame): def __init__(self, parent, current_user: dict): super().__init__(parent) self.current_user = current_user self.configure(style="TFrame") self._selected_user_id = None self._build_ui() self._load_users() # ─── Layout ─────────────────────────────────────────────────────────────── def _build_ui(self): # Top toolbar toolbar = ttk.Frame(self) toolbar.pack(fill="x", pady=(0, 10)) ttk.Label(toolbar, text="User Management", style="Heading.TLabel").pack(side="left") ttk.Button(toolbar, text="+ Add User", command=self._open_add_dialog).pack(side="right", padx=(4, 0)) ttk.Button(toolbar, text="✎ Edit", style="Ghost.TButton", command=self._open_edit_dialog).pack(side="right", padx=(4, 0)) ttk.Button(toolbar, text="✕ Delete", style="Danger.TButton", command=self._delete_selected).pack(side="right") ttk.Button(toolbar, text="🔑 Reset Password", style="Ghost.TButton", command=self._reset_password).pack(side="right", padx=(0, 8)) # Treeview cols = ("ID", "Username", "Full Name", "Email", "Role", "Active", "Created") self.tree = ttk.Treeview(self, columns=cols, show="headings", selectmode="browse") widths = [40, 130, 160, 180, 80, 60, 140] for col, w in zip(cols, widths): self.tree.heading(col, text=col) self.tree.column(col, width=w, anchor="center" if w < 120 else "w") self.tree.pack(fill="both", expand=True) self.tree.bind("", lambda _: self._open_edit_dialog()) vsb = ttk.Scrollbar(self, orient="vertical", command=self.tree.yview) self.tree.configure(yscrollcommand=vsb.set) vsb.place(relx=1, rely=0, relheight=1, anchor="ne") # ─── Data ───────────────────────────────────────────────────────────────── def _load_users(self): from models import get_all_users self.tree.delete(*self.tree.get_children()) try: for u in get_all_users(): active = "✔" if u["is_active"] else "✘" created = str(u["created_at"])[:16] if u["created_at"] else "" self.tree.insert("", "end", iid=str(u["id"]), values=(u["id"], u["username"], u["full_name"] or "", u["email"] or "", u["role"], active, created)) except Exception as e: show_error(f"Failed to load users:\n{e}") def _get_selected_id(self): sel = self.tree.selection() return int(sel[0]) if sel else None # ─── Dialogs ────────────────────────────────────────────────────────────── def _reset_password(self): """ Generate a random temporary password for the selected user, set it in the database, copy it to the clipboard, and log the action. The admin must communicate the temporary password to the user out-of-band; the user should change it immediately via Change Password. """ import random import string uid = self._get_selected_id() if not uid: show_error("Please select a user to reset.") return if uid == self.current_user["id"]: show_error("You cannot reset your own password here.\nUse Change Password instead.") return vals = self.tree.item(uid, "values") username = vals[1] if vals else str(uid) # Confirm before proceeding from tkinter import messagebox if not messagebox.askyesno( "Reset Password", f"Generate a new temporary password for '{username}'?\n\n" "The temporary password will be copied to your clipboard.", icon="warning", ): return # Build a strong random password that meets the existing policy: # 8+ chars, uppercase, digit, special character alphabet = string.ascii_letters + string.digits + "!@#$%^&*" while True: pwd = "".join(random.SystemRandom().choices(alphabet, k=16)) if (any(c.isupper() for c in pwd) and any(c.isdigit() for c in pwd) and any(c in "!@#$%^&*" for c in pwd)): break try: from models import update_user, get_user_by_id, log_action user_data = get_user_by_id(uid) if not user_data: show_error("User not found.") return update_user( self.current_user["id"], uid, user_data["username"], user_data["role"], user_data["full_name"], user_data["is_active"], password=pwd, email=user_data.get("email"), ) log_action( self.current_user["id"], "RESET_PASSWORD", "users", uid, f"Admin reset password for user '{username}' (id={uid})." ) logger.info( f"Password reset for user id={uid} '{username}' " f"by admin '{self.current_user['username']}'." ) # Show the temporary password in a purpose-built secure dialog # instead of show_info (which leaves the password visible indefinitely) _PasswordResetDialog(self, username=username, password=pwd) except Exception as e: show_error(f"Password reset failed:\n{e}") def _open_add_dialog(self): UserDialog(self, self.current_user, user_data=None, on_save=self._load_users) def _open_edit_dialog(self): uid = self._get_selected_id() if not uid: show_error("Please select a user to edit.") return from models import get_user_by_id user_data = get_user_by_id(uid) UserDialog(self, self.current_user, user_data=user_data, on_save=self._load_users) def _delete_selected(self): uid = self._get_selected_id() if not uid: show_error("Please select a user to delete.") return if uid == self.current_user["id"]: show_error("You cannot delete your own account.") return vals = self.tree.item(uid, "values") username = vals[1] if vals else str(uid) if confirm_delete(username): try: from models import delete_user delete_user(self.current_user["id"], uid) logger.info(f"Admin {self.current_user['username']} deleted user id={uid}.") show_info(f"User '{username}' deleted successfully.") self._load_users() except Exception as e: show_error(f"Delete failed:\n{e}") # ─── User Dialog (Add / Edit) ───────────────────────────────────────────────── class UserDialog(tk.Toplevel): def __init__(self, parent, current_user, user_data, on_save): super().__init__(parent) self.current_user = current_user self.user_data = user_data self.on_save = on_save self.is_edit = user_data is not None self.title("Edit User" if self.is_edit else "Add User") self.resizable(False, False) self.configure(bg=COLOURS["bg"]) self.grab_set() self._build_ui() self._centre() def _centre(self): self.update_idletasks() w, h = 440, 490 x = (self.winfo_screenwidth() - w) // 2 y = (self.winfo_screenheight() - h) // 2 self.geometry(f"{w}x{h}+{x}+{y}") def _build_ui(self): pad = {"padx": 24, "pady": 8} ttk.Label(self, text="Edit User" if self.is_edit else "New User", style="Heading.TLabel").pack(anchor="w", padx=24, pady=(20, 4)) form = ttk.Frame(self) form.pack(fill="x", padx=24, pady=8) form.columnconfigure(1, weight=1) def row(label, row_n, show=None): ttk.Label(form, text=label).grid(row=row_n, column=0, sticky="w", padx=(0, 10), pady=6) var = tk.StringVar() ent = ttk.Entry(form, textvariable=var, show=show or "") ent.grid(row=row_n, column=1, sticky="ew", pady=6) return var, ent self.full_name_var, _ = row("Full Name", 0) self.username_var, _ = row("Username", 1) self.password_var, _ = row("Password", 2, show="•") pw_hint = "(leave blank to keep unchanged)" if self.is_edit else "" ttk.Label(form, text=pw_hint, style="Dim.TLabel").grid( row=3, column=1, sticky="w") ttk.Label(form, text="Role").grid(row=4, column=0, sticky="w", padx=(0, 10), pady=6) self.role_var = tk.StringVar(value="user") role_cb = ttk.Combobox(form, textvariable=self.role_var, values=["admin", "user"], state="readonly") role_cb.grid(row=4, column=1, sticky="ew", pady=6) ttk.Label(form, text="Active").grid(row=5, column=0, sticky="w", padx=(0, 10), pady=6) self.active_var = tk.BooleanVar(value=True) ttk.Checkbutton(form, variable=self.active_var).grid(row=5, column=1, sticky="w", pady=6) ttk.Label(form, text="Email (optional)").grid(row=6, column=0, sticky="w", padx=(0, 10), pady=6) self.email_var = tk.StringVar() ttk.Entry(form, textvariable=self.email_var).grid( row=6, column=1, sticky="ew", pady=6) ttk.Label(form, text="Used for system reminders and notifications.", style="Dim.TLabel").grid(row=7, column=1, sticky="w") if self.is_edit: d = self.user_data self.full_name_var.set(d.get("full_name") or "") self.username_var.set(d.get("username") or "") self.role_var.set(d.get("role") or "user") self.active_var.set(bool(d.get("is_active", 1))) self.email_var.set(d.get("email") or "") btn_frame = ttk.Frame(self) btn_frame.pack(fill="x", padx=24, pady=(16, 20)) ttk.Button(btn_frame, text="Save", command=self._save).pack(side="right", padx=(6, 0)) ttk.Button(btn_frame, text="Cancel", style="Ghost.TButton", command=self.destroy).pack(side="right") def _save(self): full_name = self.full_name_var.get().strip() username = self.username_var.get().strip() password = self.password_var.get() role = self.role_var.get() is_active = int(self.active_var.get()) email = self.email_var.get().strip() or None if not username: show_error("Username is required.") return if not self.is_edit and not password: show_error("Password is required for new users.") return if email and "@" not in email: show_error("Please enter a valid email address.") return try: if self.is_edit: from models import update_user update_user(self.current_user["id"], self.user_data["id"], username, role, full_name, is_active, password if password else None, email=email) logger.info(f"User id={self.user_data['id']} updated by admin.") show_info("User updated successfully.") else: from models import create_user create_user(self.current_user["id"], username, password, role, full_name, email=email) logger.info(f"New user '{username}' created by admin.") show_info("User created successfully.") self.on_save() self.destroy() except Exception as e: show_error(f"Save failed:\n{e}") # ─── Secure Password Reset Dialog ───────────────────────────────────────────── class _PasswordResetDialog(tk.Toplevel): """ Purpose-built dialog for displaying a freshly-generated temporary password. Features: - Password field is masked by default; admin can reveal with 👁 toggle. - 📋 Copy button copies to clipboard with a 2-second "Copied!" flash. - Clipboard is auto-cleared after 30 seconds for security. - Dialog auto-closes after 120 seconds with a live countdown so the password cannot sit on screen indefinitely. """ _AUTO_CLOSE_S = 120 # seconds before auto-close _CLIP_CLEAR_S = 30 # seconds before clipboard is wiped def __init__(self, parent, username: str, password: str): super().__init__(parent) self._password = password self._username = username self._remaining = self._AUTO_CLOSE_S self._clip_job = None self._tick_job = None self._revealed = False self.title("Temporary Password") self.configure(bg=COLOURS["bg"]) self.resizable(False, False) self.grab_set() self.protocol("WM_DELETE_WINDOW", self._close) self._build_ui() self._centre() self._tick() def _centre(self): self.update_idletasks() w, h = 480, 300 x = (self.winfo_screenwidth() - w) // 2 y = (self.winfo_screenheight() - h) // 2 self.geometry(f"{w}x{h}+{x}+{y}") def _build_ui(self): C = COLOURS pad = dict(padx=24, pady=8) tk.Label(self, text="🔑 Password Reset", bg=C["bg"], fg=C["text"], font=FONT_BOLD).pack(anchor="w", **pad) tk.Label( self, text=f"A temporary password has been generated for '{self._username}'.\n" "Share it securely — the user must change it on first login.", bg=C["bg"], fg=C["text_dim"], font=FONT_SMALL, justify="left", wraplength=430, ).pack(anchor="w", padx=24, pady=(0, 8)) # Password row pw_frame = tk.Frame(self, bg=C["surface2"], padx=8, pady=8) pw_frame.pack(fill="x", padx=24, pady=(0, 8)) self._pw_var = tk.StringVar(value=self._password) self._pw_entry = tk.Entry( pw_frame, textvariable=self._pw_var, show="•", state="readonly", readonlybackground=C["surface2"], fg=C["text"], relief="flat", font=(FONT_BOLD[0], 14), ) self._pw_entry.pack(side="left", fill="x", expand=True) tk.Button( pw_frame, text="👁", command=self._toggle_reveal, bg=C["surface2"], fg=C["text_dim"], activebackground=C["surface"], activeforeground=C["text"], relief="flat", font=FONT_SMALL, cursor="hand2", padx=6, ).pack(side="left", padx=(6, 0)) self._copy_btn = tk.Button( pw_frame, text="📋 Copy", command=self._copy, bg=C["accent"], fg=C["white"], activebackground=C["accent_hover"], activeforeground=C["white"], relief="flat", font=FONT_SMALL, cursor="hand2", padx=10, pady=4, ) self._copy_btn.pack(side="left", padx=(6, 0)) # Countdown label self._countdown_lbl = tk.Label( self, text="", bg=C["bg"], fg=C["text_dim"], font=FONT_SMALL, ) self._countdown_lbl.pack(pady=(0, 4)) # Warning tk.Label( self, text="⚠ This dialog will close automatically. " "The clipboard is cleared after 30 seconds.", bg=C["bg"], fg=C["warning"], font=FONT_SMALL, wraplength=430, justify="left", ).pack(anchor="w", padx=24, pady=(0, 8)) # Close button tk.Button( self, text="Close", command=self._close, bg=C["surface2"], fg=C["text"], activebackground=C["danger"], activeforeground=C["white"], relief="flat", font=FONT_SMALL, cursor="hand2", padx=12, pady=6, ).pack(pady=(0, 16)) def _toggle_reveal(self): self._revealed = not self._revealed self._pw_entry.config(show="" if self._revealed else "•") def _copy(self): self.clipboard_clear() self.clipboard_append(self._password) self._copy_btn.config(text="✔ Copied!", bg=COLOURS["success"]) self.after(2000, lambda: self._copy_btn.config( text="📋 Copy", bg=COLOURS["accent"])) # Schedule clipboard wipe if self._clip_job: try: self.after_cancel(self._clip_job) except Exception: pass self._clip_job = self.after( self._CLIP_CLEAR_S * 1000, self._clear_clipboard) def _clear_clipboard(self): try: self.clipboard_clear() self.clipboard_append("") except Exception: pass def _tick(self): if not self.winfo_exists(): return self._countdown_lbl.config( text=f"Auto-closes in {self._remaining}s") if self._remaining <= 0: self._close() return self._remaining -= 1 self._tick_job = self.after(1000, self._tick) def _close(self): # Cancel any pending jobs for job in (self._clip_job, self._tick_job): if job: try: self.after_cancel(job) except Exception: pass self._clear_clipboard() try: self.destroy() except Exception: pass