""" 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 code_frame = ctk.CTkFrame(self, fg_color="#1e1e2e", corner_radius=12) code_frame.pack(fill="x", padx=15, pady=(0, 10)) self.code_label = ctk.CTkLabel( code_frame, text="------", font=ctk.CTkFont(family="Consolas", size=42, weight="bold"), text_color="#22c55e" ) self.code_label.pack(pady=(20, 5)) self.timer_label = ctk.CTkLabel( code_frame, text="", font=ctk.CTkFont(size=12), text_color="#9ca3af" ) self.timer_label.pack(pady=(0, 5)) self.progress_bar = ctk.CTkProgressBar(code_frame, width=300, height=6) self.progress_bar.pack(pady=(0, 15)) self.progress_bar.set(1.0) # 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)) 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="------") self.timer_label.configure(text="") self.progress_bar.set(1.0) 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_label.configure(text=t("totp_no_secret")) self.progress_bar.set(1.0) 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_label.configure(text=t("totp_no_secret")) self.progress_bar.set(1.0) 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_label.configure(text=t("totp_remaining").format(sec=remaining)) self.progress_bar.set(progress) # Color based on time remaining if remaining <= 5: self.code_label.configure(text_color="#ef4444") elif remaining <= 10: self.code_label.configure(text_color="#f59e0b") else: self.code_label.configure(text_color="#22c55e") except Exception as e: self.code_label.configure(text="ERROR") self.timer_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.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 _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_label.configure(text="") self.progress_bar.set(1.0) 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"))