passlocker-tk 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,19 @@
1
+ Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International Public License
2
+
3
+ By exercising the Licensed Rights (defined below), You accept and agree to be bound by the terms and conditions of this Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International Public License ("Public License").
4
+
5
+ You may not use the material for commercial purposes.
6
+
7
+ You are free to:
8
+ - Share — copy and redistribute the material in any medium or format
9
+ - Adapt — remix, transform, and build upon the material
10
+
11
+ Under the following terms:
12
+ - Attribution — You must give appropriate credit, provide a link to the license, and indicate if changes were made.
13
+ - NonCommercial — You may not use the material for commercial purposes.
14
+ - ShareAlike — If you remix, transform, or build upon the material, you must distribute your contributions under the same license as the original.
15
+ - No additional restrictions — You may not apply legal terms or technological measures that legally restrict others from doing anything the license permits.
16
+
17
+ For the full license text, see: https://creativecommons.org/licenses/by-nc-sa/4.0/legalcode
18
+
19
+ SPDX-License-Identifier: CC-BY-NC-SA-4.0
@@ -0,0 +1,33 @@
1
+ Metadata-Version: 2.4
2
+ Name: passlocker-tk
3
+ Version: 1.0.0
4
+ Summary: Password Encryption GUI
5
+ Author-email: Chris Lee <github@chrislee.dhs.org>
6
+ License: CC-BY-NC-SA-4.0
7
+ Project-URL: Homepage, https://github.com/chrislee35/passlocker-tk
8
+ Keywords: encryption,passwords,security
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Topic :: Utilities
11
+ Classifier: Programming Language :: Python :: 3 :: Only
12
+ Requires-Python: >=3.12
13
+ Description-Content-Type: text/markdown
14
+ License-File: LICENSE
15
+ Requires-Dist: passlocker>=1.0.2
16
+ Requires-Dist: customtkinter>=5.2.2
17
+ Requires-Dist: py-debounce>=0.1.2
18
+ Provides-Extra: dev
19
+ Requires-Dist: build; extra == "dev"
20
+ Requires-Dist: twine; extra == "dev"
21
+ Requires-Dist: black; extra == "dev"
22
+ Requires-Dist: ruff; extra == "dev"
23
+ Requires-Dist: setuptools; extra == "dev"
24
+ Dynamic: license-file
25
+
26
+ # Overview
27
+
28
+ PassLocker-Tk provides a Tkinter interface around using [Passlocker](https://github.com/chrislee35/passlocker), a local-first password management program.
29
+
30
+ ## Screenshot
31
+
32
+ ![Screenshot of Passlocker-Tk](imgs/screenshot-001.png)
33
+
@@ -0,0 +1,8 @@
1
+ # Overview
2
+
3
+ PassLocker-Tk provides a Tkinter interface around using [Passlocker](https://github.com/chrislee35/passlocker), a local-first password management program.
4
+
5
+ ## Screenshot
6
+
7
+ ![Screenshot of Passlocker-Tk](imgs/screenshot-001.png)
8
+
@@ -0,0 +1,3 @@
1
+ from .gui import PassLockerGui
2
+
3
+ __all__ = ["PassLockerGui"]
@@ -0,0 +1,8 @@
1
+ from .gui import PassLockerGui
2
+
3
+ def main() -> None:
4
+ app: PassLockerGui = PassLockerGui()
5
+ app.mainloop()
6
+
7
+ if __name__ == "__main__":
8
+ main()
@@ -0,0 +1,16 @@
1
+ from .password_dialog import PasswordDialog
2
+ from .accounts_table import AccountsTable
3
+ from .new_password_account_dialog import NewPasswordAccountDialog
4
+ from .new_otp_account_dialog import NewOTPAccountDialog
5
+ from .new_totp_account_dialog import NewTOTPAccountDialog
6
+ from .about_dialog import AboutDialog
7
+
8
+ __all__ = [
9
+ "PasswordDialog",
10
+ "AccountsTable",
11
+ "NewPasswordAccountDialog",
12
+ "NewOTPAccountDialog",
13
+ "NewTOTPAccountDialog",
14
+ "AboutDialog",
15
+ "PasswordDialog",
16
+ ]
@@ -0,0 +1,88 @@
1
+ import customtkinter as ctk
2
+ import webbrowser
3
+
4
+
5
+ class AboutDialog(ctk.CTkToplevel):
6
+ def __init__(
7
+ self,
8
+ parent,
9
+ *,
10
+ title,
11
+ version,
12
+ author,
13
+ github_url,
14
+ description,
15
+ ):
16
+ super().__init__(parent)
17
+
18
+ self.title("About")
19
+ self.resizable(False, False)
20
+
21
+ # Modal
22
+ self.transient(parent)
23
+ self.grab_set()
24
+
25
+ self._build_ui(
26
+ title=title,
27
+ version=version,
28
+ author=author,
29
+ github_url=github_url,
30
+ description=description,
31
+ )
32
+ self._center(parent)
33
+
34
+ self.bind("<Escape>", lambda _: self.destroy())
35
+ self.wait_window()
36
+
37
+ # --------------------
38
+ # UI
39
+ # --------------------
40
+ def _build_ui(self, *, title, version, author, github_url, description):
41
+ frame = ctk.CTkFrame(self)
42
+ frame.pack(padx=24, pady=24)
43
+
44
+ # Title
45
+ ctk.CTkLabel(frame, text=title, font=ctk.CTkFont(size=20, weight="bold")).pack(
46
+ anchor="w"
47
+ )
48
+
49
+ # Version / Author
50
+ ctk.CTkLabel(frame, text=f"Version {version}\n{author}", justify="left").pack(
51
+ anchor="w", pady=(4, 12)
52
+ )
53
+
54
+ # Description
55
+ ctk.CTkLabel(frame, text=description, justify="left", wraplength=420).pack(
56
+ anchor="w", pady=(0, 12)
57
+ )
58
+
59
+ # GitHub link
60
+ self._link(frame, text="Source code on GitHub", url=github_url).pack(
61
+ anchor="w", pady=(0, 6)
62
+ )
63
+
64
+ # License link
65
+ self._link(
66
+ frame,
67
+ text="Licensed under CC-BY-NC-SA-4.0",
68
+ url="https://creativecommons.org/licenses/by-nc-sa/4.0/legalcode",
69
+ ).pack(anchor="w", pady=(0, 16))
70
+
71
+ # Close button
72
+ ctk.CTkButton(frame, text="Close", width=120, command=self.destroy).pack(
73
+ anchor="e"
74
+ )
75
+
76
+ # --------------------
77
+ # Helpers
78
+ # --------------------
79
+ def _link(self, parent, *, text, url):
80
+ lbl = ctk.CTkLabel(parent, text=text, text_color="#4ea1ff", cursor="hand2")
81
+ lbl.bind("<Button-1>", lambda _: webbrowser.open(url))
82
+ return lbl
83
+
84
+ def _center(self, parent):
85
+ self.update_idletasks()
86
+ x = parent.winfo_x() + (parent.winfo_width() // 2) - (self.winfo_width() // 2)
87
+ y = parent.winfo_y() + (parent.winfo_height() // 2) - (self.winfo_height() // 2)
88
+ self.geometry(f"+{x}+{y}")
@@ -0,0 +1,138 @@
1
+ import customtkinter as ctk
2
+ import tkinter as tk
3
+ from hashlib import sha256
4
+ from secrets import token_bytes
5
+
6
+ from passlocker.models.account import Account
7
+
8
+
9
+ class AccountDisplayDialog(ctk.CTkToplevel):
10
+ def __init__(self, parent, *, account: Account):
11
+ super().__init__(parent)
12
+
13
+ self.title("Password Record")
14
+ self.resizable(False, False)
15
+
16
+ self.account: Account = account
17
+
18
+ self._revealed = False
19
+ self.iv: bytes = b""
20
+ self.passhash: str = ""
21
+
22
+ # Modal behavior
23
+ self.transient(parent)
24
+ self.wait_visibility()
25
+ self.grab_set()
26
+
27
+ self._build_ui()
28
+ self._center(parent)
29
+
30
+ self.bind("<Escape>", lambda _: self.destroy())
31
+ self.wait_window()
32
+
33
+ # --------------------
34
+ # UI
35
+ # --------------------
36
+ def _build_ui(self):
37
+ container: ctk.CTkFrame = ctk.CTkFrame(self)
38
+ container.pack(padx=10, pady=10)
39
+
40
+ # Account
41
+ ctk.CTkLabel(container, text="Account").grid(
42
+ row=0, column=0, sticky="w", padx=5
43
+ )
44
+ ctk.CTkLabel(
45
+ container, text=self.account.account, font=ctk.CTkFont(weight="bold")
46
+ ).grid(row=0, column=1, sticky="w")
47
+
48
+ # Username
49
+ ctk.CTkLabel(container, text="Username").grid(
50
+ row=1, column=0, sticky="w", padx=5
51
+ )
52
+ ctk.CTkLabel(container, text=self.account.username).grid(
53
+ row=1, column=1, sticky="w"
54
+ )
55
+
56
+ # Created on
57
+ ctk.CTkLabel(container, text="Created").grid(
58
+ row=2, column=0, sticky="w", padx=5
59
+ )
60
+ ctk.CTkLabel(container, text=self.account.created_on).grid(
61
+ row=2, column=1, sticky="w"
62
+ )
63
+
64
+ # Password
65
+ ctk.CTkLabel(container, text="Password").grid(
66
+ row=3, column=0, sticky="w", padx=5
67
+ )
68
+
69
+ self.password_var: ctk.StringVar = ctk.StringVar(value="••••••••")
70
+ self.password_label: ctk.CTkLabel = ctk.CTkLabel(
71
+ container, textvariable=self.password_var, font=ctk.CTkFont(size=14)
72
+ )
73
+ self.password_label.grid(row=3, column=1, sticky="w")
74
+
75
+ # Buttons
76
+ btn_frame: ctk.CTkFrame = ctk.CTkFrame(container, fg_color="transparent")
77
+ btn_frame.grid(row=4, column=1, sticky="w", pady=(10, 0))
78
+
79
+ self.reveal_btn: ctk.CTkButton = ctk.CTkButton(
80
+ btn_frame, text="Show Password", width=100, command=self.toggle_reveal
81
+ )
82
+ self.reveal_btn.grid(row=0, column=0, padx=(0, 10))
83
+
84
+ self.copy_btn: ctk.CTkButton = ctk.CTkButton(
85
+ btn_frame, text="Copy to Clipboard", width=100, command=self.copy_password
86
+ )
87
+ self.copy_btn.grid(row=0, column=1)
88
+
89
+ container.grid_columnconfigure(1, weight=1)
90
+
91
+ # --------------------
92
+ # Actions
93
+ # --------------------
94
+ def toggle_reveal(self):
95
+ self._revealed = not self._revealed
96
+
97
+ if self._revealed:
98
+ password: bytes = self.account.get_active_password()
99
+ self.password_var.set(password.decode("utf-8"))
100
+ self.reveal_btn.configure(text="Hide")
101
+ else:
102
+ self.password_var.set("••••••••")
103
+ self.reveal_btn.configure(text="Reveal")
104
+
105
+ def copy_password(self):
106
+ self.clipboard_clear()
107
+ password: bytes = self.account.get_active_password()
108
+ self.iv: bytes = token_bytes(16)
109
+ self.passhash: str = sha256(self.iv + password).hexdigest()
110
+ self.clipboard_append(password.decode("utf-8"))
111
+ self.update() # ensure clipboard is updated
112
+ self._schedule_clipboard_clear()
113
+
114
+ def _schedule_clipboard_clear(self, delay_ms=15000):
115
+ self.after(delay_ms, self._clear_clipboard)
116
+
117
+ def _clear_clipboard(self):
118
+ try:
119
+ checkhash: str = sha256(
120
+ self.iv + self.clipboard_get().encode("utf-8")
121
+ ).hexdigest()
122
+ if checkhash == self.passhash:
123
+ self.clipboard_clear()
124
+ except tk.TclError:
125
+ pass
126
+
127
+ # --------------------
128
+ # Helpers
129
+ # --------------------
130
+ def _center(self, parent):
131
+ self.update_idletasks()
132
+ x: int = (
133
+ parent.winfo_x() + (parent.winfo_width() // 2) - (self.winfo_width() // 2)
134
+ )
135
+ y: int = (
136
+ parent.winfo_y() + (parent.winfo_height() // 2) - (self.winfo_height() // 2)
137
+ )
138
+ self.geometry(f"+{x}+{y}")
@@ -0,0 +1,65 @@
1
+ import customtkinter as ctk
2
+ import tkinter as tk
3
+ from tkinter import ttk
4
+ from passlocker.models.account import Account
5
+ from .account_display_dialog import AccountDisplayDialog
6
+
7
+
8
+ class AccountsTable(ctk.CTkFrame):
9
+ def __init__(self, parent: ctk.CTk) -> None:
10
+ super().__init__(parent)
11
+
12
+ columns = ("account", "username", "type")
13
+
14
+ self.tree: ttk.Treeview = ttk.Treeview(
15
+ self, columns=columns, show="headings", height=10
16
+ )
17
+
18
+ # Column definitions
19
+ self.tree.heading("account", text="Account")
20
+ self.tree.heading("username", text="Username")
21
+ self.tree.heading("type", text="Type")
22
+
23
+ self.tree.column("account", anchor="w", width=200)
24
+ self.tree.column("username", anchor="w", width=180)
25
+ self.tree.column("type", anchor="center", width=80)
26
+
27
+ # Scrollbar
28
+ scrollbar = ttk.Scrollbar(self, orient="vertical", command=self.tree.yview)
29
+ self.tree.configure(yscrollcommand=scrollbar.set)
30
+
31
+ # Layout
32
+ self.tree.grid(row=0, column=0, sticky="nsew")
33
+ scrollbar.grid(row=0, column=1, sticky="ns")
34
+
35
+ self.grid_rowconfigure(0, weight=1)
36
+ self.grid_columnconfigure(0, weight=1)
37
+
38
+ self.tree.bind("<Double-1>", self.on_open_account)
39
+ self._accounts_by_id: dict[int, Account] = {}
40
+
41
+ # --------------------
42
+ # Public API
43
+ # --------------------
44
+ def populate(self, accounts: list[Account]) -> None:
45
+ """
46
+ accounts: iterable of objects or dicts
47
+ expected fields: account, username, type
48
+ """
49
+ self.tree.delete(*self.tree.get_children())
50
+
51
+ for acc in accounts:
52
+ self.tree.insert(
53
+ "", "end", iid=id(acc), values=(acc.account, acc.username, acc.type)
54
+ )
55
+ self._accounts_by_id: dict[int, Account] = {id(acc): acc for acc in accounts}
56
+
57
+ def on_open_account(self, event: tk.Event) -> None:
58
+ selected = self.tree.selection()
59
+ if not selected:
60
+ return
61
+ item_id = int(selected[0])
62
+ account = self._accounts_by_id.get(item_id)
63
+ if account is None:
64
+ return
65
+ _ = AccountDisplayDialog(self, account=account)
@@ -0,0 +1,209 @@
1
+ import customtkinter as ctk
2
+ from tkinter import ttk
3
+ import secrets
4
+
5
+
6
+ class GeneratePasswordDialog(ctk.CTkToplevel):
7
+ def __init__(self, parent) -> None:
8
+ super().__init__(parent)
9
+
10
+ self.title("Generate Password")
11
+ self.resizable(False, False)
12
+
13
+ self.result: str | None = None
14
+
15
+ # Modal behavior
16
+ self.transient(parent)
17
+ self.wait_visibility()
18
+ self.grab_set()
19
+
20
+ self._build_ui()
21
+ self._center(parent)
22
+
23
+ self.bind("<Escape>", lambda _: self._cancel())
24
+
25
+ self.wait_window()
26
+
27
+ # --------------------
28
+ # UI
29
+ # --------------------
30
+ def _build_ui(self) -> None:
31
+ root = ctk.CTkFrame(self)
32
+ root.pack(padx=20, pady=20)
33
+
34
+ # ---- Controls ----
35
+ controls = ctk.CTkFrame(root)
36
+ controls.pack(fill="x")
37
+
38
+ # Algorithm selector
39
+ ctk.CTkLabel(controls, text="Algorithm").grid(row=0, column=0, sticky="w")
40
+
41
+ self.algorithm_var = ctk.StringVar(value="random")
42
+ self.algorithm_menu = ctk.CTkOptionMenu(
43
+ controls,
44
+ variable=self.algorithm_var,
45
+ values=["random", "memorable", "numeric"],
46
+ width=140,
47
+ )
48
+ self.algorithm_menu.grid(row=0, column=1, padx=(10, 20))
49
+
50
+ # Length selector
51
+ ctk.CTkLabel(controls, text="Length").grid(row=0, column=2, sticky="w")
52
+
53
+ self.length_var = ctk.IntVar(value=16)
54
+ self.length_slider = ctk.CTkSlider(
55
+ controls, from_=8, to=64, number_of_steps=56, command=self._on_length_change
56
+ )
57
+ self.length_slider.set(16)
58
+ self.length_slider.grid(row=0, column=3, padx=(10, 10))
59
+
60
+ self.length_label = ctk.CTkLabel(controls, text="16", width=32, anchor="e")
61
+ self.length_label.grid(row=0, column=4)
62
+
63
+ controls.grid_columnconfigure(3, weight=1)
64
+
65
+ # Generate button
66
+ self.generate_btn = ctk.CTkButton(root, text="Generate", command=self._generate)
67
+ self.generate_btn.pack(pady=(10, 10))
68
+
69
+ # ---- Table ----
70
+ table_frame = ctk.CTkFrame(root)
71
+ table_frame.pack()
72
+
73
+ self.tree = ttk.Treeview(
74
+ table_frame, columns=("password",), show="headings", height=10
75
+ )
76
+ self.tree.heading("password", text="Generated Password")
77
+ self.tree.column("password", anchor="w", width=320)
78
+
79
+ scrollbar = ttk.Scrollbar(
80
+ table_frame, orient="vertical", command=self.tree.yview
81
+ )
82
+ self.tree.configure(yscrollcommand=scrollbar.set)
83
+
84
+ self.tree.grid(row=0, column=0, sticky="nsew")
85
+ scrollbar.grid(row=0, column=1, sticky="ns")
86
+
87
+ table_frame.grid_rowconfigure(0, weight=1)
88
+ table_frame.grid_columnconfigure(0, weight=1)
89
+
90
+ # Double-click selection
91
+ self.tree.bind("<Double-1>", self._on_select)
92
+
93
+ # --------------------
94
+ # Actions
95
+ # --------------------
96
+ def _on_length_change(self, value):
97
+ self.length_label.configure(text=str(int(value)))
98
+
99
+ def _generate(self):
100
+ algorithm = self.algorithm_var.get()
101
+ length = self.length_var.get()
102
+
103
+ passwords = self.generate_passwords(algorithm, length, count=10)
104
+ self.tree.delete(*self.tree.get_children())
105
+
106
+ for pw in passwords:
107
+ self.tree.insert("", "end", values=(pw,))
108
+
109
+ def _on_select(self, event):
110
+ selection = self.tree.selection()
111
+ if not selection:
112
+ return
113
+
114
+ self.result = self.tree.item(selection[0], "values")[0]
115
+ self.destroy()
116
+
117
+ def _cancel(self):
118
+ self.result = None
119
+ self.destroy()
120
+
121
+ # --------------------
122
+ # Helpers
123
+ # --------------------
124
+ def _center(self, parent):
125
+ self.update_idletasks()
126
+ x = parent.winfo_x() + (parent.winfo_width() // 2) - (self.winfo_width() // 2)
127
+ y = parent.winfo_y() + (parent.winfo_height() // 2) - (self.winfo_height() // 2)
128
+ self.geometry(f"+{x}+{y}")
129
+
130
+ def generate_passwords(
131
+ self, algorithm: str, length: int, count: int = 10
132
+ ) -> list[str]:
133
+ if algorithm == "memorable":
134
+ return [self.generate_memorable(length) for _ in range(count)]
135
+ elif algorithm == "random":
136
+ return [self.generate_random(length) for _ in range(count)]
137
+ elif algorithm == "numeric":
138
+ return [self.generate_numbers(length) for _ in range(count)]
139
+ else:
140
+ raise Exception(f"Unknown generation algorithm: {algorithm}")
141
+
142
+ def generate_memorable(self, length: int) -> str:
143
+ if length <= 8:
144
+ maxnum = 100
145
+ special = 0
146
+ elif length <= 16:
147
+ maxnum = 1000
148
+ special = 1
149
+ elif length <= 24:
150
+ maxnum = 10000
151
+ special = 2
152
+ else:
153
+ maxnum = 100000
154
+ special = 3
155
+
156
+ random_int: int = secrets.randbelow(maxnum)
157
+ random_special: str = "".join(
158
+ secrets.choice(
159
+ [
160
+ "!",
161
+ "@",
162
+ "#",
163
+ "$",
164
+ "%",
165
+ "^",
166
+ "&",
167
+ "*",
168
+ "(",
169
+ ")",
170
+ "-",
171
+ "_",
172
+ "+",
173
+ "=",
174
+ "{",
175
+ "}",
176
+ "[",
177
+ "]",
178
+ ":",
179
+ ";",
180
+ "<",
181
+ ">",
182
+ ".",
183
+ ",",
184
+ "?",
185
+ "/",
186
+ "~",
187
+ "`",
188
+ ]
189
+ )
190
+ for i in range(special)
191
+ )
192
+
193
+ secret_sauce = f"{random_int}{random_special}"
194
+ password: str = ""
195
+
196
+ with open("/usr/share/dict/words") as f:
197
+ words: list[str] = [word.strip() for word in f]
198
+ genlen: int = 0
199
+ while genlen != length:
200
+ password = secret_sauce.join(secrets.choice(words) for _ in range(2))
201
+ genlen = len(password)
202
+
203
+ return password
204
+
205
+ def generate_random(self, length: int) -> str:
206
+ return secrets.token_urlsafe(32)[0:length]
207
+
208
+ def generate_numbers(self, length: int) -> str:
209
+ return "".join([str(secrets.choice(range(10))) for i in range(length)])
@@ -0,0 +1,94 @@
1
+ import customtkinter as ctk
2
+
3
+
4
+ class NewOTPAccountDialog(ctk.CTkToplevel):
5
+ def __init__(self, parent):
6
+ super().__init__(parent)
7
+
8
+ self.title("New Account")
9
+ self.resizable(False, False)
10
+
11
+ self.result = None
12
+
13
+ # Modal behavior
14
+ self.transient(parent)
15
+ self.grab_set()
16
+
17
+ self._build_ui()
18
+ self._center(parent)
19
+
20
+ self.bind("<Escape>", lambda _: self._cancel())
21
+ self.bind("<Return>", lambda _: self._ok())
22
+
23
+ self.wait_window()
24
+
25
+ # --------------------
26
+ # UI
27
+ # --------------------
28
+ def _build_ui(self):
29
+ frame = ctk.CTkFrame(self)
30
+ frame.pack(padx=20, pady=20)
31
+
32
+ # Variables
33
+ self.account_var = ctk.StringVar()
34
+ self.username_var = ctk.StringVar()
35
+
36
+ # Account name
37
+ ctk.CTkLabel(frame, text="Account").grid(row=0, column=0, sticky="w")
38
+ self.account_entry = ctk.CTkEntry(
39
+ frame, textvariable=self.account_var, width=260
40
+ )
41
+ self.account_entry.grid(row=0, column=1, pady=5)
42
+ self.account_entry.focus()
43
+
44
+ # Username
45
+ ctk.CTkLabel(frame, text="Username").grid(row=1, column=0, sticky="w")
46
+ ctk.CTkEntry(frame, textvariable=self.username_var, width=260).grid(
47
+ row=1, column=1, pady=5
48
+ )
49
+
50
+ # Password
51
+ ctk.CTkLabel(frame, text="Passwords").grid(row=2, column=0, sticky="w")
52
+ self.password_entry = ctk.CTkTextbox(frame, width=260, height=112)
53
+ self.password_entry.grid(row=2, column=1, pady=5)
54
+
55
+ # Buttons
56
+ btn_frame = ctk.CTkFrame(frame, fg_color="transparent")
57
+ btn_frame.grid(row=3, column=1, pady=(15, 0), sticky="e")
58
+
59
+ ctk.CTkButton(btn_frame, text="Cancel", width=100, command=self._cancel).grid(
60
+ row=0, column=0, padx=(0, 10)
61
+ )
62
+
63
+ self.ok_btn = ctk.CTkButton(btn_frame, text="OK", width=100, command=self._ok)
64
+ self.ok_btn.grid(row=0, column=1)
65
+
66
+ frame.grid_columnconfigure(1, weight=1)
67
+
68
+ def _ok(self):
69
+ account = self.account_var.get().strip()
70
+ username = self.username_var.get().strip()
71
+ password = self.password_entry.get("0.0", ctk.END)
72
+
73
+ if not account or not password:
74
+ return # minimal validation; extend as needed
75
+
76
+ self.result = {
77
+ "account": account,
78
+ "username": username,
79
+ "password": password,
80
+ }
81
+ self.destroy()
82
+
83
+ def _cancel(self):
84
+ self.result = None
85
+ self.destroy()
86
+
87
+ # --------------------
88
+ # Helpers
89
+ # --------------------
90
+ def _center(self, parent):
91
+ self.update_idletasks()
92
+ x = parent.winfo_x() + (parent.winfo_width() // 2) - (self.winfo_width() // 2)
93
+ y = parent.winfo_y() + (parent.winfo_height() // 2) - (self.winfo_height() // 2)
94
+ self.geometry(f"+{x}+{y}")