Files
server-manager/gui/tabs/totp_tab.py
chrome-storm-c442 1e729fcf3a v1.9.1: PNG Material Design icons — 28 icons, dark/light theme, HiDPI, graceful Unicode fallback
- 56 PNG icons (28 unique × 2 color variants) from Material Design Icons (round style, 96×96px)
- core/icons.py: ctk_icon(), make_icon_button(), reconfigure_icon_button() with CTkImage cache
- Updated 15 GUI files: app.py, sidebar.py, server_dialog.py, all tabs
- build.py: auto-include assets/icons/ in PyInstaller bundle, patch rollover at 99→minor+1
- tools/download_icons.py: icon download script
- Automatic dark↔light theme switching via CTkImage dual-image support

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 07:27:49 -05:00

343 lines
13 KiB
Python

"""
TOTP tab — Google Authenticator compatible 2FA codes.
Live countdown, one-click copy, per-server secrets.
"""
import threading
import customtkinter as ctk
from core.i18n import t
from core.icons import icon_text, make_icon_button, reconfigure_icon_button
class TOTPTab(ctk.CTkFrame):
def __init__(self, master, store):
super().__init__(master, fg_color="transparent")
self.store = store
self._current_alias: str | None = None
self._timer_id = None
# Title
self.title_label = ctk.CTkLabel(
self, text=t("totp_title"),
font=ctk.CTkFont(size=16, weight="bold"), anchor="w"
)
self.title_label.pack(fill="x", padx=15, pady=(15, 5))
# Description
self.desc_label = ctk.CTkLabel(
self, text=t("totp_desc"),
anchor="w", text_color="#9ca3af", wraplength=600, justify="left"
)
self.desc_label.pack(fill="x", padx=15, pady=(0, 10))
# Server name
self.server_label = ctk.CTkLabel(
self, text=t("no_server_selected"),
font=ctk.CTkFont(size=13), anchor="w", text_color="#6b7280"
)
self.server_label.pack(fill="x", padx=15, pady=(0, 10))
# Code display frame — clickable
self.code_frame = ctk.CTkFrame(self, fg_color="#1e1e2e", corner_radius=16, cursor="hand2")
self.code_frame.pack(fill="x", padx=15, pady=(0, 10))
self.code_label = ctk.CTkLabel(
self.code_frame, text="------",
font=ctk.CTkFont(family="Consolas", size=52, weight="bold"),
text_color="#22c55e", cursor="hand2"
)
self.code_label.pack(pady=(25, 2))
# Timer: big seconds number + label
timer_row = ctk.CTkFrame(self.code_frame, fg_color="transparent", cursor="hand2")
timer_row.pack(pady=(0, 8))
self.timer_sec_label = ctk.CTkLabel(
timer_row, text="",
font=ctk.CTkFont(family="Consolas", size=28, weight="bold"),
text_color="#22c55e"
)
self.timer_sec_label.pack(side="left")
self.timer_unit_label = ctk.CTkLabel(
timer_row, text="",
font=ctk.CTkFont(size=14),
text_color="#6b7280"
)
self.timer_unit_label.pack(side="left", padx=(4, 0), pady=(6, 0))
self.progress_bar = ctk.CTkProgressBar(
self.code_frame, width=400, height=10,
corner_radius=5,
progress_color="#22c55e",
fg_color="#2a2a3e"
)
self.progress_bar.pack(pady=(0, 20), padx=40)
self.progress_bar.set(1.0)
# Click anywhere on the code frame → copy
for widget in (self.code_frame, self.code_label, timer_row,
self.timer_sec_label, self.timer_unit_label):
widget.bind("<Button-1>", lambda e: self._copy_code())
# Copy button
self.copy_btn = make_icon_button(
self, "copy", t("totp_copy"), width=200, height=40,
font=ctk.CTkFont(size=14),
fg_color="#22c55e", hover_color="#16a34a",
command=self._copy_code
)
self.copy_btn.pack(pady=(5, 15))
# Secret management section
secret_frame = ctk.CTkFrame(self, fg_color="transparent")
secret_frame.pack(fill="x", padx=15, pady=(10, 5))
ctk.CTkLabel(
secret_frame, text=t("totp_secret_label"),
font=ctk.CTkFont(size=13, weight="bold"), anchor="w"
).pack(fill="x")
entry_row = ctk.CTkFrame(secret_frame, fg_color="transparent")
entry_row.pack(fill="x", pady=(5, 0))
self.secret_entry = ctk.CTkEntry(
entry_row, show="*",
placeholder_text=t("totp_secret_placeholder"),
font=ctk.CTkFont(family="Consolas", size=12)
)
self.secret_entry.pack(side="left", fill="x", expand=True, padx=(0, 5))
self.show_secret_btn = make_icon_button(
entry_row, "eye", t("show"), width=80,
fg_color="#6b7280", hover_color="#4b5563",
command=self._toggle_secret
)
self.show_secret_btn.pack(side="left", padx=(0, 5))
self._secret_visible = False
self.save_secret_btn = make_icon_button(
entry_row, "confirm", t("totp_save_secret"), width=110,
command=self._save_secret
)
self.save_secret_btn.pack(side="left", padx=(0, 5))
self.remove_secret_btn = make_icon_button(
entry_row, "delete", t("totp_remove_secret"), width=110,
fg_color="#ef4444", hover_color="#dc2626",
command=self._remove_secret
)
self.remove_secret_btn.pack(side="left")
# Generate random secret button
self.gen_secret_btn = make_icon_button(
secret_frame, "key", t("totp_generate_secret"), width=200,
fg_color="#6b7280", hover_color="#4b5563",
command=self._generate_secret
)
self.gen_secret_btn.pack(anchor="w", pady=(8, 0))
# Status log
self.status_label = ctk.CTkLabel(
self, text="", anchor="w", text_color="#9ca3af"
)
self.status_label.pack(fill="x", padx=15, pady=(5, 10))
def set_server(self, alias: str | None):
self._current_alias = alias
self._stop_timer()
if not alias:
self.server_label.configure(text=t("no_server_selected"))
self.code_label.configure(text="------", text_color="#22c55e")
self.timer_sec_label.configure(text="")
self.timer_unit_label.configure(text="")
self.progress_bar.set(1.0)
self.progress_bar.configure(progress_color="#6b7280")
self.secret_entry.delete(0, "end")
self.status_label.configure(text="")
return
self.server_label.configure(text=f"🖥 {alias}")
server = self.store.get_server(alias)
if not server:
return
secret = server.get("totp_secret", "")
self.secret_entry.delete(0, "end")
if secret:
self.secret_entry.insert(0, secret)
self._start_timer()
else:
self.code_label.configure(text="------")
self.timer_sec_label.configure(text="")
self.timer_unit_label.configure(text=t("totp_no_secret"))
self.progress_bar.set(1.0)
self.progress_bar.configure(progress_color="#6b7280")
def _start_timer(self):
self._stop_timer()
self._update_code()
def _stop_timer(self):
if self._timer_id:
self.after_cancel(self._timer_id)
self._timer_id = None
def _update_code(self):
if not self._current_alias:
return
server = self.store.get_server(self._current_alias)
if not server:
return
secret = server.get("totp_secret", "")
if not secret:
self.code_label.configure(text="------")
self.timer_sec_label.configure(text="")
self.timer_unit_label.configure(text=t("totp_no_secret"))
self.progress_bar.set(1.0)
self.progress_bar.configure(progress_color="#6b7280")
return
try:
from core.totp import get_code_with_timer
data = get_code_with_timer(secret)
code = data["code"]
remaining = data["remaining"]
progress = data["progress"]
# Format code with space in middle: "123 456"
formatted = f"{code[:3]} {code[3:]}"
self.code_label.configure(text=formatted)
self.timer_sec_label.configure(text=str(remaining))
self.timer_unit_label.configure(text="sec")
self.progress_bar.set(progress)
# Color based on time remaining — code, timer, progress bar
if remaining <= 5:
color = "#ef4444"
elif remaining <= 10:
color = "#f59e0b"
else:
color = "#22c55e"
self.code_label.configure(text_color=color)
self.timer_sec_label.configure(text_color=color)
self.progress_bar.configure(progress_color=color)
except Exception as e:
self.code_label.configure(text="ERROR")
self.timer_sec_label.configure(text="")
self.timer_unit_label.configure(text=str(e))
# Schedule next update in 1 second
self._timer_id = self.after(1000, self._update_code)
def _copy_code(self):
code_text = self.code_label.cget("text").replace(" ", "")
if code_text and code_text != "------" and code_text != "ERROR":
self.clipboard_clear()
self.clipboard_append(code_text)
self._animate_copy()
self.status_label.configure(text=t("totp_copied"), text_color="#22c55e")
self.after(2000, lambda: self.status_label.configure(text=""))
else:
self.status_label.configure(text=t("totp_no_code"), text_color="#ef4444")
self.after(2000, lambda: self.status_label.configure(text=""))
def _animate_copy(self):
"""Flash the code frame and briefly show 'Copied' feedback."""
# Save original state
orig_fg = self.code_frame.cget("fg_color")
orig_text = self.code_label.cget("text")
orig_color = self.code_label.cget("text_color")
# Flash: green background + "Copied!" text
self.code_frame.configure(fg_color="#164e36")
self.code_label.configure(text="", text_color="#4ade80")
def restore():
self.code_frame.configure(fg_color=orig_fg)
self.code_label.configure(text=orig_text, text_color=orig_color)
self.after(400, restore)
def _toggle_secret(self):
self._secret_visible = not self._secret_visible
self.secret_entry.configure(show="" if self._secret_visible else "*")
reconfigure_icon_button(
self.show_secret_btn, "eye", t("hide") if self._secret_visible else t("show")
)
def _save_secret(self):
if not self._current_alias:
self.status_label.configure(text=t("no_server_selected"), text_color="#ef4444")
return
secret = self.secret_entry.get().strip()
if not secret:
self.status_label.configure(text=t("totp_secret_empty"), text_color="#ef4444")
self.after(2000, lambda: self.status_label.configure(text=""))
return
# Validate secret
try:
from core.totp import get_code
get_code(secret)
except Exception:
self.status_label.configure(text=t("totp_secret_invalid"), text_color="#ef4444")
self.after(2000, lambda: self.status_label.configure(text=""))
return
server = self.store.get_server(self._current_alias)
if server:
server["totp_secret"] = secret
self.store.update_server(self._current_alias, server)
self.status_label.configure(text=t("totp_secret_saved"), text_color="#22c55e")
self.after(2000, lambda: self.status_label.configure(text=""))
self._start_timer()
def _remove_secret(self):
if not self._current_alias:
return
server = self.store.get_server(self._current_alias)
if server and "totp_secret" in server:
del server["totp_secret"]
self.store.update_server(self._current_alias, server)
self.secret_entry.delete(0, "end")
self._stop_timer()
self.code_label.configure(text="------", text_color="#22c55e")
self.timer_sec_label.configure(text="")
self.timer_unit_label.configure(text="")
self.progress_bar.set(1.0)
self.progress_bar.configure(progress_color="#6b7280")
self.status_label.configure(text=t("totp_secret_removed"), text_color="#9ca3af")
self.after(2000, lambda: self.status_label.configure(text=""))
def _generate_secret(self):
try:
from core.totp import generate_secret
secret = generate_secret()
self.secret_entry.delete(0, "end")
self.secret_entry.insert(0, secret)
self.status_label.configure(text=t("totp_secret_generated"), text_color="#22c55e")
self.after(2000, lambda: self.status_label.configure(text=""))
except Exception as e:
self.status_label.configure(text=f"Error: {e}", text_color="#ef4444")
def update_language(self):
self.title_label.configure(text=t("totp_title"))
self.desc_label.configure(text=t("totp_desc"))
reconfigure_icon_button(self.copy_btn, "copy", t("totp_copy"))
reconfigure_icon_button(self.save_secret_btn, "confirm", t("totp_save_secret"))
reconfigure_icon_button(self.remove_secret_btn, "delete", t("totp_remove_secret"))
reconfigure_icon_button(self.gen_secret_btn, "key", t("totp_generate_secret"))
reconfigure_icon_button(
self.show_secret_btn, "eye", t("hide") if self._secret_visible else t("show")
)
if not self._current_alias:
self.server_label.configure(text=t("no_server_selected"))