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