""" 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 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("", lambda e: self._copy_code()) # Copy button self.copy_btn = ctk.CTkButton( self, text=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)) # Enable undo functionality self.secret_entry._entry.config(undo=True) self.show_secret_btn = ctk.CTkButton( entry_row, text=t("show"), width=70, 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 = ctk.CTkButton( entry_row, text=t("totp_save_secret"), width=100, command=self._save_secret ) self.save_secret_btn.pack(side="left", padx=(0, 5)) self.remove_secret_btn = ctk.CTkButton( entry_row, text=t("totp_remove_secret"), width=100, 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 = ctk.CTkButton( secret_frame, text=t("totp_generate_secret"), width=180, 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 "*") self.show_secret_btn.configure(text=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")) self.copy_btn.configure(text=t("totp_copy")) self.save_secret_btn.configure(text=t("totp_save_secret")) self.remove_secret_btn.configure(text=t("totp_remove_secret")) self.gen_secret_btn.configure(text=t("totp_generate_secret")) self.show_secret_btn.configure( text=t("hide") if self._secret_visible else t("show") ) if not self._current_alias: self.server_label.configure(text=t("no_server_selected"))