04/21 Fisrt commit
This commit is contained in:
@@ -0,0 +1,249 @@
|
||||
"""
|
||||
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)
|
||||
Reference in New Issue
Block a user