474 lines
19 KiB
Python
474 lines
19 KiB
Python
"""
|
||
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("<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["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 |