Files
WebChecker/views/admin_users_view.py
T

459 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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", "Role", "Active", "Created")
self.tree = ttk.Treeview(self, columns=cols, show="headings", selectmode="browse")
widths = [40, 140, 180, 80, 60, 160]
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("<Double-1>", 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["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,
)
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 = 420, 420
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)
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)))
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())
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
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)
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)
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