250 lines
9.5 KiB
Python
250 lines
9.5 KiB
Python
"""
|
||
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()) # 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)
|