MIL-002 Password Manager Features #24

Merged
Tirsvad merged 5 commits from mil-002-password-manager-features into main 2026-10-14 15:26:30 +02:00
7 changed files with 360 additions and 0 deletions
Showing only changes of commit d096481453 - Show all commits
+8
View File
@@ -0,0 +1,8 @@
"""@brief Entry point for `python -m password_manager`.
@file __main__.py
"""
from password_manager.main import main
main()
+39
View File
@@ -0,0 +1,39 @@
"""@brief Composition root: wires the layers together and runs the window.
@file main.py
"""
import tkinter as tk
from password_manager import constants
from password_manager.application.use_cases import (
FindPasswordEntry,
GeneratePassword,
SavePasswordEntry,
)
from password_manager.infrastructure.persistence.json_password_repository import (
JsonPasswordRepository,
)
from password_manager.presentation.features.password_manager import (
password_manager_view as view_module,
)
from password_manager.presentation.features.password_manager import (
password_manager_view_model as view_model_module,
)
def main() -> None:
"""@brief Build the application and run the Tk main loop."""
repository = JsonPasswordRepository(constants.DATA_FILE_PATH)
view_model = view_model_module.PasswordManagerViewModel(
SavePasswordEntry(repository),
FindPasswordEntry(repository),
GeneratePassword(),
)
root = tk.Tk()
view_module.PasswordManagerView(root, view_model)
root.mainloop()
if __name__ == "__main__":
main()
@@ -0,0 +1 @@
"""@brief Package marker."""
@@ -0,0 +1 @@
"""@brief Package marker."""
@@ -0,0 +1 @@
"""@brief Package marker."""
@@ -0,0 +1,156 @@
"""@brief Tkinter view of the password manager screen.
@file password_manager_view.py
"""
import tkinter as tk
from tkinter import messagebox
from password_manager import constants
from password_manager.presentation.features.password_manager import (
password_manager_view_model as view_model_module,
)
class PasswordManagerView:
"""@brief The window: shows the fields and raises events to the view model."""
def __init__(
self, root: tk.Tk, view_model: view_model_module.PasswordManagerViewModel
) -> None:
"""@brief Build the widgets on a root window.
@param root The Tk root window.
@param view_model The view model the events are forwarded to.
"""
self._root = root
self._view_model = view_model
self._view_model.bind_ui(self._copy_to_clipboard, self._confirm_save)
root.title(constants.WINDOW_TITLE)
root.config(padx=constants.WINDOW_PADDING, pady=constants.WINDOW_PADDING)
self._build_logo()
self._build_form()
@property
def website_entry(self) -> tk.Entry:
"""@return The website input."""
return self._website_entry
@property
def email_entry(self) -> tk.Entry:
"""@return The e-mail input."""
return self._email_entry
@property
def password_entry(self) -> tk.Entry:
"""@return The password input."""
return self._password_entry
def generate_password(self) -> None:
"""@brief Fill the password field with a new password (and copy it)."""
password = self._view_model.generate_password()
self._password_entry.delete(0, tk.END)
self._password_entry.insert(0, password)
def save(self) -> None:
"""@brief Ask the view model to store the entered details."""
result = self._view_model.save(
self._website_entry.get(),
self._email_entry.get(),
self._password_entry.get(),
)
if result is None:
return
self._show(result)
if result.clear_fields:
self._clear_fields()
def find_password(self) -> None:
"""@brief Show the stored details of the entered website."""
self._show(self._view_model.find_password(self._website_entry.get()))
def _build_logo(self) -> None:
size = constants.LOGO_SIZE
canvas = tk.Canvas(
self._root,
width=size,
height=size,
bg=constants.LOGO_BACKGROUND,
highlightthickness=0,
)
canvas.create_arc(
*constants.LOGO_SHACKLE_BOX,
start=0,
extent=180,
style=tk.ARC,
outline=constants.LOGO_SHACKLE_COLOR,
width=constants.LOGO_SHACKLE_WIDTH,
)
canvas.create_rectangle(
*constants.LOGO_BODY_BOX,
fill=constants.LOGO_BODY_COLOR,
outline=constants.LOGO_BODY_COLOR,
)
canvas.create_oval(
*constants.LOGO_KEYHOLE_BOX,
fill=constants.LOGO_KEYHOLE_COLOR,
outline=constants.LOGO_KEYHOLE_COLOR,
)
canvas.create_rectangle(
*constants.LOGO_KEYHOLE_SLOT,
fill=constants.LOGO_KEYHOLE_COLOR,
outline=constants.LOGO_KEYHOLE_COLOR,
)
canvas.grid(row=0, column=1)
def _build_form(self) -> None:
root = self._root
tk.Label(root, text=constants.LABEL_WEBSITE).grid(row=1, column=0)
tk.Label(root, text=constants.LABEL_EMAIL).grid(row=2, column=0)
tk.Label(root, text=constants.LABEL_PASSWORD).grid(row=3, column=0)
self._website_entry = tk.Entry(root, width=constants.ENTRY_WIDTH_NARROW)
self._website_entry.grid(row=1, column=1, sticky="EW")
self._website_entry.focus()
self._email_entry = tk.Entry(root, width=constants.ENTRY_WIDTH_WIDE)
self._email_entry.grid(row=2, column=1, columnspan=2, sticky="EW")
self._email_entry.insert(0, constants.DEFAULT_EMAIL)
self._password_entry = tk.Entry(root, width=constants.ENTRY_WIDTH_NARROW)
self._password_entry.grid(row=3, column=1, sticky="EW")
self._search_button = tk.Button(
root, text=constants.BUTTON_SEARCH, command=self.find_password
)
self._search_button.grid(row=1, column=2, sticky="EW")
self._generate_button = tk.Button(
root, text=constants.BUTTON_GENERATE, command=self.generate_password
)
self._generate_button.grid(row=3, column=2, sticky="EW")
self._add_button = tk.Button(
root,
text=constants.BUTTON_ADD,
width=constants.ENTRY_WIDTH_WIDE,
command=self.save,
)
self._add_button.grid(row=4, column=1, columnspan=2, sticky="EW")
def _copy_to_clipboard(self, text: str) -> None:
self._root.clipboard_clear()
self._root.clipboard_append(text)
@staticmethod
def _confirm_save(message: str) -> bool:
return bool(
messagebox.askokcancel(title=constants.WINDOW_TITLE, message=message)
)
@staticmethod
def _show(result: view_model_module.ActionResult) -> None:
if result.is_error:
messagebox.showwarning(title=result.title, message=result.message)
else:
messagebox.showinfo(title=result.title, message=result.message)
def _clear_fields(self) -> None:
self._website_entry.delete(0, tk.END)
self._password_entry.delete(0, tk.END)
@@ -0,0 +1,154 @@
"""@brief View model of the password manager screen (no tkinter here).
@file password_manager_view_model.py
"""
from collections.abc import Callable
from dataclasses import dataclass
from password_manager import constants
from password_manager.application.use_cases import (
FindPasswordEntry,
GeneratePassword,
SavePasswordEntry,
)
from password_manager.domain.errors import (
DataFileNotFoundError,
EmptyFieldError,
EntryNotFoundError,
InvalidDataFileError,
)
@dataclass(frozen=True, slots=True)
class ActionResult:
"""@brief What the view shows after an action."""
title: str ##< Title of the message box.
message: str ##< Text of the message box.
is_error: bool ##< True when the box is a warning rather than information.
clear_fields: bool = False ##< True when the view clears website and password.
def _ignore_clipboard(text: str) -> None:
"""@brief Default clipboard: discards the text until the view binds one."""
def _decline_save(message: str) -> bool:
"""@brief Default confirmation: declines until the view binds a dialog."""
return False
class PasswordManagerViewModel:
"""@brief Data and commands of the password manager screen."""
def __init__(
self,
save_entry: SavePasswordEntry,
find_entry: FindPasswordEntry,
generate: GeneratePassword,
copy_to_clipboard: Callable[[str], None] = _ignore_clipboard,
confirm_save: Callable[[str], bool] = _decline_save,
) -> None:
"""@brief Create the view model.
@param save_entry Use case that stores an entry.
@param find_entry Use case that looks an entry up.
@param generate Use case that generates a password.
@param copy_to_clipboard Puts text on the clipboard.
@param confirm_save Asks the user to confirm a save; True means ok.
"""
self._save_entry = save_entry
self._find_entry = find_entry
self._generate = generate
self._copy_to_clipboard = copy_to_clipboard
self._confirm_save = confirm_save
def bind_ui(
self,
copy_to_clipboard: Callable[[str], None],
confirm_save: Callable[[str], bool],
) -> None:
"""@brief Hand the view's clipboard and confirmation dialog to the model.
@param copy_to_clipboard Puts text on the clipboard.
@param confirm_save Asks the user to confirm a save; True means ok.
"""
self._copy_to_clipboard = copy_to_clipboard
self._confirm_save = confirm_save
def generate_password(self) -> str:
"""@brief Generate a password and copy it to the clipboard.
@return The generated password.
"""
password = self._generate.execute()
self._copy_to_clipboard(password)
return password
def save(self, website: str, email: str, password: str) -> ActionResult | None:
"""@brief Confirm and store the entry.
@param website Website field.
@param email E-mail field.
@param password Password field.
@return A result to show, or None when the user cancelled.
"""
if not (website.strip() and email.strip() and password.strip()):
return ActionResult(
constants.TITLE_OOPS, constants.MESSAGE_EMPTY_FIELDS, True
)
question = constants.MESSAGE_CONFIRM_SAVE.format(email=email, password=password)
if not self._confirm_save(question):
return None
try:
self._save_entry.execute(website, email, password)
except EmptyFieldError:
return ActionResult(
constants.TITLE_OOPS, constants.MESSAGE_EMPTY_FIELDS, True
)
except InvalidDataFileError:
return ActionResult(
constants.TITLE_OOPS, constants.MESSAGE_INVALID_DATA_FILE, True
)
else:
return ActionResult(
constants.TITLE_SAVED,
constants.MESSAGE_SAVED.format(website=website.strip()),
False,
clear_fields=True,
)
def find_password(self, website: str) -> ActionResult:
"""@brief Look up the credentials of a website.
@param website Website field.
@return The result to show.
"""
try:
entry = self._find_entry.execute(website)
except EmptyFieldError:
return ActionResult(
constants.TITLE_OOPS, constants.MESSAGE_EMPTY_FIELDS, True
)
except DataFileNotFoundError:
return ActionResult(
constants.TITLE_OOPS, constants.MESSAGE_NO_DATA_FILE, True
)
except EntryNotFoundError as error:
return ActionResult(
constants.TITLE_OOPS,
constants.MESSAGE_NO_DETAILS.format(website=error.website),
True,
)
except InvalidDataFileError:
return ActionResult(
constants.TITLE_OOPS, constants.MESSAGE_INVALID_DATA_FILE, True
)
return ActionResult(
entry.website,
constants.MESSAGE_DETAILS.format(
email=entry.email, password=entry.password
),
False,
)