Files
WebChecker/views/change_password_view.py
T
2026-04-21 17:16:37 -04:00

250 lines
9.5 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/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("<Return>", 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()) # 04
# 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)