""" 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("", 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()