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

222 lines
8.8 KiB
Python

"""
views/login_view.py — Login screen with rate limiting.
Calls check_login_allowed() before authenticate().
On lockout, shows remaining wait time (updated every second).
On successful login, the failed attempt counter is cleared by authenticate().
"""
import tkinter as tk
from tkinter import ttk
import logging
from utils.ui_helpers import COLOURS, FONT, FONT_BOLD, FONT_TITLE, show_error
logger = logging.getLogger("login_view")
class LoginView(tk.Toplevel):
def __init__(self, master, on_success_callback):
super().__init__(master)
self.on_success = on_success_callback
self._countdown_job = None # after() handle for lockout countdown
self.title("Website Checker - Login")
self.resizable(False, False)
self.configure(bg=COLOURS["bg"])
self._centre()
self._build_ui()
self.protocol("WM_DELETE_WINDOW", self._on_close)
self.grab_set()
def _centre(self):
self.update_idletasks()
w, h = 420, 380
x = (self.winfo_screenwidth() - w) // 2
y = (self.winfo_screenheight() - h) // 2
self.geometry(f"{w}x{h}+{x}+{y}")
def _build_ui(self):
outer = tk.Frame(self, bg=COLOURS["bg"], padx=40, pady=30)
outer.pack(fill="both", expand=True)
# Logo / Title
tk.Label(outer, text="🌐 Website Checker",
font=FONT_TITLE,
bg=COLOURS["bg"],
fg=COLOURS["accent"]).pack(pady=(0, 4))
tk.Label(outer, text="Shift Monitoring Tool",
font=(FONT[0], 9),
bg=COLOURS["bg"],
fg=COLOURS["text_dim"]).pack(pady=(0, 24))
# Form
form = tk.Frame(outer, bg=COLOURS["bg"])
form.pack(fill="x")
form.columnconfigure(0, weight=1)
tk.Label(form, text="Username", bg=COLOURS["bg"],
fg=COLOURS["text_dim"], font=FONT_BOLD).grid(
row=0, column=0, sticky="w", pady=(0, 2))
self.username_var = tk.StringVar()
self.username_ent = tk.Entry(
form, textvariable=self.username_var, width=32,
bg=COLOURS["surface2"], fg=COLOURS["text"],
insertbackground=COLOURS["text"],
relief="flat", font=FONT)
self.username_ent.grid(row=1, column=0, sticky="ew",
ipady=8, pady=(0, 14))
self.username_ent.focus_set()
tk.Label(form, text="Password", bg=COLOURS["bg"],
fg=COLOURS["text_dim"], font=FONT_BOLD).grid(
row=2, column=0, sticky="w", pady=(0, 2))
self.password_var = tk.StringVar()
self.password_ent = tk.Entry(
form, textvariable=self.password_var, show="•", width=32,
bg=COLOURS["surface2"], fg=COLOURS["text"],
insertbackground=COLOURS["text"],
relief="flat", font=FONT)
self.password_ent.grid(row=3, column=0, sticky="ew",
ipady=8, pady=(0, 20))
self.password_ent.bind("<Return>", lambda _: self._login())
# Error / lockout label
self.error_label = tk.Label(outer, text="",
fg=COLOURS["danger"],
bg=COLOURS["bg"], font=FONT,
wraplength=320, justify="center")
self.error_label.pack(pady=(0, 8))
# Sign-in button
self.sign_in_btn = tk.Button(
outer, text=" Sign In ",
command=self._login,
bg=COLOURS["accent"],
fg=COLOURS["white"],
activebackground=COLOURS["accent_hover"],
activeforeground=COLOURS["white"],
relief="flat",
font=FONT_BOLD,
cursor="hand2",
padx=20, pady=8)
self.sign_in_btn.pack()
# ─── Login ────────────────────────────────────────────────────────────────
def _login(self):
username = self.username_var.get().strip()
password = self.password_var.get()
if not username or not password:
self.error_label.config(text="Please enter both username and password.")
return
# ── Rate-limit check ──────────────────────────────────────────────────
try:
from models import check_login_allowed, LOCKOUT_MINUTES
allowed, seconds_left = check_login_allowed(username)
except Exception as e:
logger.error(f"Rate-limit check error: {e}")
show_error(f"Unable to connect to the database.\n\n{e}",
"Connection Error")
return
if not allowed:
self._start_lockout_countdown(seconds_left)
return
# ── Authenticate ──────────────────────────────────────────────────────
try:
from models import authenticate
user = authenticate(username, password)
except Exception as e:
logger.error(f"Authentication error: {e}")
show_error(f"Unable to connect to the database.\n\n{e}",
"Connection Error")
return
if user:
self._cancel_countdown()
logger.info(f"Login successful for '{username}' (role={user['role']}).")
self.destroy()
self.on_success(user)
else:
# Show remaining attempts warning
try:
from models import check_login_allowed, MAX_FAILED_ATTEMPTS
_, secs = check_login_allowed(username)
if secs > 0:
self._start_lockout_countdown(secs)
else:
# Fetch fresh count to show "N attempts remaining"
from config import get_connection
conn = get_connection()
cur = conn.cursor(dictionary=True)
cur.execute(
"SELECT failed_attempts FROM users WHERE username=%s",
(username,)
)
row = cur.fetchone()
cur.close()
conn.close()
if row:
remaining = MAX_FAILED_ATTEMPTS - (row["failed_attempts"] or 0)
remaining = max(0, remaining)
if remaining > 0:
self.error_label.config(
text=f"Invalid credentials. "
f"{remaining} attempt(s) remaining before lockout."
)
else:
self.error_label.config(
text="Invalid credentials. Please try again.")
else:
self.error_label.config(
text="Invalid credentials. Please try again.")
except Exception:
self.error_label.config(
text="Invalid credentials. Please try again.")
# ─── Lockout countdown ────────────────────────────────────────────────────
def _start_lockout_countdown(self, seconds_left: int):
"""Disable the form and show a live countdown until the lockout expires."""
self.username_ent.config(state="disabled")
self.password_ent.config(state="disabled")
self.sign_in_btn.config(state="disabled", bg=COLOURS["border"])
self._remaining = seconds_left
self._tick_countdown()
def _tick_countdown(self):
if self._remaining <= 0:
self._cancel_countdown()
return
mins, secs = divmod(self._remaining, 60)
self.error_label.config(
text=f"Account temporarily locked.\n"
f"Try again in {mins}m {secs:02d}s."
)
self._remaining -= 1
self._countdown_job = self.after(1000, self._tick_countdown)
def _cancel_countdown(self):
if self._countdown_job:
try:
self.after_cancel(self._countdown_job)
except Exception:
pass
self._countdown_job = None
try:
self.username_ent.config(state="normal")
self.password_ent.config(state="normal")
self.sign_in_btn.config(state="normal", bg=COLOURS["accent"])
self.error_label.config(text="")
except Exception:
pass # widget may have been destroyed
def _on_close(self):
self._cancel_countdown()
self.master.destroy()