""" views/change_password_view.py — Self-service password change dialog. Available to all authenticated users via the sidebar. Enforces strength rules and verifies the current password before accepting. """ 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, ) logger = logging.getLogger("change_password_view") class ChangePasswordView(tk.Toplevel): """ Modal dialog for changing the logged-in user's password. Parameters ---------- master : parent widget current_user : dict with at least 'id' and 'username' """ def __init__(self, master, current_user: dict): super().__init__(master) self.current_user = current_user self.title("Change Password") self.configure(bg=COLOURS["bg"]) self.resizable(False, False) self.grab_set() self._build_ui() self._centre() def _centre(self): self.update_idletasks() w, h = 440, 460 x = (self.winfo_screenwidth() - w) // 2 y = (self.winfo_screenheight() - h) // 2 self.geometry(f"{w}x{h}+{x}+{y}") # ─── Layout ─────────────────────────────────────────────────────────────── def _build_ui(self): # Header banner hdr = tk.Frame(self, bg=COLOURS["accent"], pady=16) hdr.pack(fill="x") tk.Label(hdr, text="Change Password", font=FONT_BOLD, bg=COLOURS["accent"], fg=COLOURS["white"]).pack() tk.Label(hdr, text=f"Logged in as: {self.current_user.get('username', '')}", font=FONT_SMALL, bg=COLOURS["accent"], fg=COLOURS["white"]).pack(pady=(2, 0)) # Form form = tk.Frame(self, bg=COLOURS["bg"], padx=36, pady=20) form.pack(fill="both", expand=True) form.columnconfigure(1, weight=1) def field(label, row, show="•"): tk.Label(form, text=label, bg=COLOURS["bg"], fg=COLOURS["text_dim"], font=FONT_SMALL, anchor="w").grid(row=row, column=0, sticky="w", padx=(0, 12), pady=6) var = tk.StringVar() ent = tk.Entry(form, textvariable=var, show=show, bg=COLOURS["surface2"], fg=COLOURS["text"], insertbackground=COLOURS["text"], relief="flat", font=FONT) ent.grid(row=row, column=1, sticky="ew", ipady=7, pady=6) return var, ent self.old_var, self.old_ent = field("Current Password", 0) self.new_var, _ = field("New Password", 1) self.cfm_var, _ = field("Confirm Password", 2) self.old_ent.focus_set() # Bind new-password field to live strength meter self.new_var.trace_add("write", lambda *_: self._update_strength()) # ── Password strength meter ─────────────────────────────────────────── tk.Label(form, text="Strength", bg=COLOURS["bg"], fg=COLOURS["text_dim"], font=FONT_SMALL).grid( row=3, column=0, sticky="nw", padx=(0, 12), pady=(8, 2)) meter_frame = tk.Frame(form, bg=COLOURS["bg"]) meter_frame.grid(row=3, column=1, sticky="ew", pady=(8, 2)) # Five segment bars self._segments = [] for i in range(5): seg = tk.Frame(meter_frame, bg=COLOURS["surface2"], width=36, height=8) seg.pack(side="left", padx=2) seg.pack_propagate(False) self._segments.append(seg) self._strength_lbl = tk.Label(meter_frame, text="", bg=COLOURS["bg"], fg=COLOURS["text_dim"], font=FONT_SMALL) self._strength_lbl.pack(side="left", padx=(10, 0)) # Requirements checklist tk.Label(form, text="Requirements", bg=COLOURS["bg"], fg=COLOURS["text_dim"], font=FONT_SMALL).grid( row=4, column=0, sticky="nw", padx=(0, 12), pady=(12, 4)) req_frame = tk.Frame(form, bg=COLOURS["bg"]) req_frame.grid(row=4, column=1, sticky="ew", pady=(12, 4)) from models import (PW_MIN_LENGTH, PW_REQUIRE_UPPER, PW_REQUIRE_DIGIT, PW_REQUIRE_SPECIAL) self._req_labels = {} req_defs = [ ("length", f"At least {PW_MIN_LENGTH} characters"), ("upper", "Uppercase letter"), ("digit", "Number"), ("special", "Special character"), ] for key, text in req_defs: lbl = tk.Label(req_frame, text=f" {text}", bg=COLOURS["bg"], fg=COLOURS["text_dim"], font=FONT_SMALL, anchor="w") lbl.pack(fill="x") self._req_labels[key] = lbl # Status / error line self._status_var = tk.StringVar() tk.Label(form, textvariable=self._status_var, bg=COLOURS["bg"], fg=COLOURS["danger"], font=FONT_SMALL, wraplength=340, justify="left").grid( row=5, column=0, columnspan=2, sticky="w", pady=(8, 0)) # Buttons btn_row = tk.Frame(self, bg=COLOURS["bg"], padx=36, pady=16) btn_row.pack(fill="x") tk.Button( btn_row, text="Change Password", command=self._submit, bg=COLOURS["accent"], fg=COLOURS["white"], activebackground=COLOURS["accent_hover"], activeforeground=COLOURS["white"], relief="flat", font=FONT_BOLD, cursor="hand2", padx=14, pady=8, ).pack(side="right", padx=(8, 0)) tk.Button( btn_row, text="Cancel", command=self.destroy, bg=COLOURS["surface"], fg=COLOURS["text_dim"], activebackground=COLOURS["surface2"], relief="flat", font=FONT_SMALL, cursor="hand2", padx=10, pady=8, ).pack(side="right") # Enter key submits self.bind("", lambda _: self._submit()) # ─── Strength Meter ─────────────────────────────────────────────────────── def _update_strength(self): from models import (validate_password_strength, PW_MIN_LENGTH, PW_REQUIRE_UPPER, PW_REQUIRE_DIGIT, PW_REQUIRE_SPECIAL, _SPECIAL_CHARS) pw = self.new_var.get() # Evaluate each requirement met = { "length": len(pw) >= PW_MIN_LENGTH, "upper": any(c.isupper() for c in pw), "digit": any(c.isdigit() for c in pw), "special": any(c in _SPECIAL_CHARS for c in pw), } score = sum(met.values()) # 0–4 # Update requirement labels for key, lbl in self._req_labels.items(): if met[key]: lbl.config(fg=COLOURS["success"], text=f"✔ {lbl.cget('text')[2:]}") else: lbl.config(fg=COLOURS["text_dim"], text=f" {lbl.cget('text')[2:]}") # Re-set correct prefix each time req_texts = { "length": f"At least {PW_MIN_LENGTH} characters", "upper": "Uppercase letter", "digit": "Number", "special": "Special character", } for key, lbl in self._req_labels.items(): prefix = "✔ " if met[key] else " " lbl.config(text=prefix + req_texts[key]) # Colour segments colours = ["#e05c5c", "#f0a500", "#f0a500", "#7c6af7", "#4caf50"] labels = ["Very Weak", "Weak", "Fair", "Strong", "Very Strong"] for i, seg in enumerate(self._segments): seg.config(bg=colours[score - 1] if i < score and score > 0 else COLOURS["surface2"]) if pw: self._strength_lbl.config( text=labels[score - 1] if score > 0 else "", fg=colours[score - 1] if score > 0 else COLOURS["text_dim"] ) else: self._strength_lbl.config(text="") # ─── Submit ─────────────────────────────────────────────────────────────── def _submit(self): old_pw = self.old_var.get() new_pw = self.new_var.get() cfm_pw = self.cfm_var.get() if not old_pw or not new_pw or not cfm_pw: self._status_var.set("All fields are required.") return if new_pw != cfm_pw: self._status_var.set("New password and confirmation do not match.") return from models import change_password try: success, message = change_password( self.current_user["id"], old_pw, new_pw ) except Exception as e: self._status_var.set(f"Error: {e}") return if success: show_info(message) logger.info( f"Password changed by user '{self.current_user['username']}'.") self.destroy() else: self._status_var.set(message)