From f4c8185ed01c137122be2d59ae63ab7cb2476b13 Mon Sep 17 00:00:00 2001 From: Admin Date: Tue, 14 Oct 2025 16:45:19 +0700 Subject: [PATCH] login --- config.py | 5 + database/db.py | 3 +- facebook_marketplace.db | Bin 49152 -> 40960 bytes fb_window.py | 217 ++++++++++++++++++ gui/core/login_handle_dialog.py | 102 +++++--- gui/global_signals.py | 4 +- gui/handle/login_fb.py | 174 ++++++++++++++ gui/main_window.py | 80 ++++--- gui/tabs/accounts/account_tab.py | 80 +++++-- services/action_service.py | 91 ++++++++ services/detect_service.py | 104 +++++++++ services/profile_service.py | 142 ++++++++++++ stores/shared_store.py | 35 +++ tasks/listed_tasks.py | 153 ++++-------- .../login/Screenshot 2025-10-12 220051.png | Bin 0 -> 1697 bytes .../Screenshot 2025-10-14 at 16.23.16.png | Bin 0 -> 28604 bytes .../Screenshot 2025-10-14 at 13.44.40.png | Bin 0 -> 3737 bytes .../Screenshot 2025-10-14 at 15.53.32.png | Bin 0 -> 3800 bytes templates/password/tpl_password.png | Bin 0 -> 1721 bytes .../Screenshot 2025-10-14 at 13.44.22.png | Bin 0 -> 5375 bytes .../Screenshot 2025-10-14 at 13.45.45.png | Bin 0 -> 5392 bytes templates/username/tpl_username.png | Bin 0 -> 2761 bytes 22 files changed, 986 insertions(+), 204 deletions(-) create mode 100644 fb_window.py create mode 100644 gui/handle/login_fb.py create mode 100644 services/action_service.py create mode 100644 services/detect_service.py create mode 100644 services/profile_service.py create mode 100644 stores/shared_store.py create mode 100644 templates/buttons/login/Screenshot 2025-10-12 220051.png create mode 100644 templates/login_fail/Screenshot 2025-10-14 at 16.23.16.png create mode 100644 templates/password/Screenshot 2025-10-14 at 13.44.40.png create mode 100644 templates/password/Screenshot 2025-10-14 at 15.53.32.png create mode 100644 templates/password/tpl_password.png create mode 100644 templates/username/Screenshot 2025-10-14 at 13.44.22.png create mode 100644 templates/username/Screenshot 2025-10-14 at 13.45.45.png create mode 100644 templates/username/tpl_username.png diff --git a/config.py b/config.py index e69de29..b6b826b 100644 --- a/config.py +++ b/config.py @@ -0,0 +1,5 @@ +import os + +# 📂 Thư mục profiles gốc +PROFILES_DIR = os.path.join(os.getcwd(), "profiles") +TEMPLATE_DIR = os.path.join(os.getcwd(), "templates") diff --git a/database/db.py b/database/db.py index 12a5097..397fb1d 100644 --- a/database/db.py +++ b/database/db.py @@ -18,7 +18,8 @@ def create_tables(): id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT NOT NULL UNIQUE, password TEXT NOT NULL, - is_active INTEGER DEFAULT 1 + is_active INTEGER DEFAULT 1, + login_at INTEGER DEFAULT NULL ) ''') diff --git a/facebook_marketplace.db b/facebook_marketplace.db index 2684a4aae53d455d4fe0080ea8f5cd68d66114ef..b296e94261d82543bcd75ad806e11f2fca69645c 100644 GIT binary patch delta 411 zcmZo@U~V|TG(nnGoq>UYYoda^ygGwk-Y#DL9}JuvoD6(h`H%A@b3fxV;ylT{lY^6U z#>U2O_RS_7YuLD&_!-&7Wn~$g3n#zgyDY53rGNx-^3yZ(CI@heu=s`g_)Ol*@6N%* zf02RzIse7Yf&xeRtwosF7?g!M7?K>)a}zUj^pf*)4ULS&8G)jboD7L5qCg2Qp!1mc zA2RTN;3~g1KeI9;l4d48W@Sz!ZM^)SfEunc@L%PB0@QGl-;kG)S(d3dwWK67 zFTI$dg@=h*mN79oIlnZoq?ie4JRcB40WXLa*evMqmVaUZFB`~mBraZAK9DRke-#7& z4gOX9RY0qA_}TQB*%@s4C)f8&^PA?C<|d^UWxRd$0fZ;7>Q`rnv2XM%!q|%@2mk;T CL~|Vg literal 49152 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 threshold) + + for pt in zip(*loc[::-1]): + top_left = (int(pt[0]), int(pt[1])) + bottom_right = ( + int(pt[0] + template.shape[1]), + int(pt[1] + template.shape[0]), + ) + score = float(res[pt[1], pt[0]]) + regions.append((folder_name, file, top_left, bottom_right, score)) + + # --- Lọc bớt trùng bằng Non-Max Suppression --- + filtered = self.non_max_suppression(regions, overlap_thresh=0.3) + + if not filtered: + self.status.setText("[WARN] Không phát hiện được gì") + print("[WARN] Không phát hiện được gì") + return + + # --- Vẽ preview --- + for folder_name, _, top_left, bottom_right, _ in filtered: + cv2.rectangle(annotated, top_left, bottom_right, (0, 255, 0), 2) + cv2.putText( + annotated, + folder_name, + (top_left[0], top_left[1] - 5), + cv2.FONT_HERSHEY_SIMPLEX, + 0.6, + (0, 255, 0), + 2, + ) + + self.status.setText(f"[INFO] Phát hiện {len(filtered)} vùng hợp lệ") + print(f"[INFO] Phát hiện {len(filtered)} vùng hợp lệ") + + self.show_preview(annotated) + QTimer.singleShot(800, lambda: self.autofill_by_detection(filtered)) + + # ---------------------------------------------------- + def non_max_suppression(self, regions, overlap_thresh=0.3): + """Giảm trùng vùng detect""" + if not regions: + return [] + + boxes = [] + for folder_name, file, top_left, bottom_right, score in regions: + x1, y1 = top_left + x2, y2 = bottom_right + boxes.append([x1, y1, x2, y2, score, folder_name, file]) + boxes = sorted(boxes, key=lambda x: x[4], reverse=True) + + pick = [] + while boxes: + current = boxes.pop(0) + pick.append(current) + boxes = [ + b + for b in boxes + if b[5] != current[5] # khác folder_name (chỉ giữ 1 mỗi loại) + and self.iou(b, current) < overlap_thresh + ] + return [ + (b[5], b[6], (int(b[0]), int(b[1])), (int(b[2]), int(b[3])), b[4]) + for b in pick + ] + + # ---------------------------------------------------- + def iou(self, boxA, boxB): + """Intersection-over-Union""" + xA = max(boxA[0], boxB[0]) + yA = max(boxA[1], boxB[1]) + xB = min(boxA[2], boxB[2]) + yB = min(boxA[3], boxB[3]) + interArea = max(0, xB - xA) * max(0, yB - yA) + boxAArea = (boxA[2] - boxA[0]) * (boxA[3] - boxA[1]) + boxBArea = (boxB[2] - boxB[0]) * (boxB[3] - boxB[1]) + iou = interArea / float(boxAArea + boxBArea - interArea + 1e-5) + return iou + + # ---------------------------------------------------- + def autofill_by_detection(self, regions): + """Tự động click/gõ text theo vùng detect""" + for folder_name, filename, top_left, bottom_right, score in regions: + print(f"[ACTION] {folder_name}: {filename} ({score:.2f})") + label = folder_name.lower() + + if "user" in label or "email" in label: + print("[DO] Điền email...") + self.action.write_in_region( + top_left, bottom_right, "myemail@example.com" + ) + + elif "pass" in label: + print("[DO] Điền mật khẩu...") + self.action.write_in_region(top_left, bottom_right, "mypassword123") + + elif "button" in label or "login" in label: + print("[DO] Click nút đăng nhập... (tạm comment)") + # self.action.click_center_of_region(top_left, bottom_right) + + # ---------------------------------------------------- + def show_preview(self, bgr_img): + """Hiển thị preview trong PyQt5""" + rgb = cv2.cvtColor(bgr_img, cv2.COLOR_BGR2RGB) + h, w = rgb.shape[:2] + qimg = QImage(rgb.data, w, h, rgb.strides[0], QImage.Format.Format_RGB888) + pix = QPixmap.fromImage(qimg) + + dlg = QDialog(self) + dlg.setWindowTitle("Detection Preview") + v_layout = QVBoxLayout(dlg) + lbl = QLabel() + lbl.setPixmap(pix.scaled(800, 600, Qt.KeepAspectRatio)) + v_layout.addWidget(lbl) + dlg.exec_() + + +if __name__ == "__main__": + app = QApplication(sys.argv) + win = FBWindow() + win.show() + sys.exit(app.exec_()) diff --git a/gui/core/login_handle_dialog.py b/gui/core/login_handle_dialog.py index 46878d0..e0eb26d 100644 --- a/gui/core/login_handle_dialog.py +++ b/gui/core/login_handle_dialog.py @@ -1,44 +1,80 @@ -# gui/dialogs/login_handle_dialog.py -from PyQt5.QtWidgets import QDialog, QVBoxLayout, QLabel, QProgressBar, QPushButton -from PyQt5.QtCore import QTimer, Qt +# gui/core/login_handle_dialog.py +from PyQt5.QtWidgets import QDialog, QVBoxLayout, QPushButton, QApplication +from PyQt5.QtCore import QTimer +from services.core.log_service import log_service +from stores.shared_store import SharedStore from gui.global_signals import global_signals + class LoginHandleDialog(QDialog): - def __init__(self, account_id=None, duration=10, parent=None): - super().__init__(parent) + dialog_width = 300 + dialog_height = 100 + margin = 10 # khoảng cách giữa các dialog + + # Lưu danh sách dialog đang mở (dùng class variable để chia sẻ giữa tất cả dialog) + open_dialogs = [] + + def __init__(self, account_id: int, listed_id: int): + super().__init__() self.account_id = account_id - self.duration = duration - self.elapsed = 0 + self.listed_id = listed_id - self.setWindowTitle(f"Login Handle - Account {self.account_id}") - self.setFixedSize(300, 150) + self.setWindowTitle(f"Handle Listing {self.listed_id}") + self.setModal(False) # modeless + self.resize(self.dialog_width, self.dialog_height) + # --- UI đơn giản --- layout = QVBoxLayout() - self.label = QLabel(f"Processing account_id={self.account_id}...") - self.label.setAlignment(Qt.AlignCenter) - layout.addWidget(self.label) - - self.progress = QProgressBar() - self.progress.setRange(0, self.duration) - layout.addWidget(self.progress) - - self.btn_cancel = QPushButton("Cancel") - self.btn_cancel.clicked.connect(self.reject) - layout.addWidget(self.btn_cancel) - + self.btn_finish = QPushButton("Finish Listing") + self.btn_finish.clicked.connect(self.finish_listing) + layout.addWidget(self.btn_finish) self.setLayout(layout) - self.timer = QTimer(self) - self.timer.timeout.connect(self._update_progress) - self.timer.start(1000) + # --- Tính vị trí để xếp dialog từ góc trái trên cùng sang phải theo hàng ngang --- + self.move_to_corner() + LoginHandleDialog.open_dialogs.append(self) - def _update_progress(self): - self.elapsed += 1 - self.progress.setValue(self.elapsed) - if self.elapsed >= self.duration: - self.close() + def move_to_corner(self): + screen_geometry = QApplication.primaryScreen().availableGeometry() + start_x = screen_geometry.left() + self.margin + start_y = screen_geometry.top() + self.margin - def closeEvent(self, event): - """Emit signal khi dialog đóng""" - global_signals.dialog_finished.emit(self.account_id) - super().closeEvent(event) + row_height = 0 + current_x = start_x + current_y = start_y + + for dlg in LoginHandleDialog.open_dialogs: + # Nếu vượt chiều ngang màn hình, xuống hàng mới + if current_x + dlg.width() + self.margin > screen_geometry.right(): + current_x = start_x + current_y += row_height + self.margin + row_height = 0 + + dlg.move(current_x, current_y) + current_x += dlg.width() + self.margin + row_height = max(row_height, dlg.height()) + + # vị trí dialog mới + if current_x + self.width() + self.margin > screen_geometry.right(): + current_x = start_x + current_y += row_height + self.margin + + self.move(current_x, current_y) + + def finish_listing(self): + """Remove khỏi SharedStore, emit signal và đóng dialog""" + try: + store = SharedStore.get_instance() + store.remove(self.listed_id) + log_service.info(f"[Dialog] Removed listed_id={self.listed_id} from SharedStore") + + # Emit signal để MainWindow biết + QTimer.singleShot(0, lambda: global_signals.dialog_finished.emit(self.account_id, self.listed_id)) + + # Close dialog an toàn + self.hide() + self.deleteLater() + if self in LoginHandleDialog.open_dialogs: + LoginHandleDialog.open_dialogs.remove(self) + except Exception as e: + log_service.error(f"[Dialog] Exception in finish_listing for listed_id={self.listed_id}: {e}") diff --git a/gui/global_signals.py b/gui/global_signals.py index 973dc27..68ace94 100644 --- a/gui/global_signals.py +++ b/gui/global_signals.py @@ -3,5 +3,7 @@ from PyQt5.QtCore import QObject, pyqtSignal class GlobalSignals(QObject): listed_finished = pyqtSignal() + open_login_dialog = pyqtSignal(int, int) # account_id, listed_id + dialog_finished = pyqtSignal(int, int) # account_id, listed_id -global_signals = GlobalSignals() +global_signals = GlobalSignals() \ No newline at end of file diff --git a/gui/handle/login_fb.py b/gui/handle/login_fb.py new file mode 100644 index 0000000..3686ac7 --- /dev/null +++ b/gui/handle/login_fb.py @@ -0,0 +1,174 @@ +# gui/handle/login_fb.py +import os +import sys +import cv2 +import numpy as np +from PyQt5.QtCore import Qt, QUrl, QTimer +from PyQt5.QtWidgets import QApplication, QMainWindow, QVBoxLayout, QWidget, QLabel, QTextEdit, QPushButton +from PyQt5.QtWebEngineWidgets import QWebEngineView +from PyQt5.QtGui import QImage, QPixmap + +from services.action_service import ActionService +from services.detect_service import DetectService +from config import TEMPLATE_DIR + + +class LoginFB(QMainWindow): + def __init__(self, account=None, delay=0.1): + super().__init__() + self.account = account or {} + self.template_dir = os.path.abspath(TEMPLATE_DIR) + self.delay = delay + + # --- Detect services --- + self.detector = DetectService( + template_dir=TEMPLATE_DIR, + target_labels=["username", "password", "buttons/login"] + ) + + # --- Detect login fail templates --- + self.fail_detector = DetectService( + template_dir=os.path.join(TEMPLATE_DIR, "login_fail") + ) + + # --- UI --- + self.setWindowTitle("FB Auto Vision Login") + self.setFixedSize(480, 680) + + self.web = QWebEngineView() + self.web.setUrl(QUrl("https://facebook.com")) + self.web.setFixedSize(480, 480) + + self.status = QLabel("Status: Ready") + self.status.setAlignment(Qt.AlignLeft) + self.status.setFixedHeight(20) + + self.log_area = QTextEdit() + self.log_area.setReadOnly(True) + self.log_area.setFixedHeight(120) + self.log_area.setStyleSheet(""" + background-color: #1e1e1e; + color: #dcdcdc; + font-size: 12px; + font-family: Consolas, monospace; + """) + + self.btn_refresh = QPushButton("Refresh") + self.btn_refresh.setFixedHeight(30) + self.btn_refresh.clicked.connect(self.refresh_page) + + layout = QVBoxLayout() + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(self.web) + layout.addWidget(self.status) + layout.addWidget(self.log_area) + layout.addWidget(self.btn_refresh) + + container = QWidget() + container.setLayout(layout) + self.setCentralWidget(container) + + self.action = ActionService(webview=self.web, delay=self.delay) + self.login_clicked = False + + # Giữ reference popup (nếu cần) – hiện tại không dùng + # self.login_fail_popup = None + + self.web.loadFinished.connect(self.on_web_loaded) + + # ---------------------------------------------------- + def log(self, message: str): + self.log_area.append(message) + print(message) + + # ---------------------------------------------------- + def capture_webview(self): + pixmap = self.web.grab() + if pixmap.isNull(): + return None + qimg = pixmap.toImage().convertToFormat(QImage.Format_RGBA8888) + width, height = qimg.width(), qimg.height() + ptr = qimg.bits() + ptr.setsize(height * width * 4) + arr = np.frombuffer(ptr, np.uint8).reshape((height, width, 4)) + return cv2.cvtColor(arr, cv2.COLOR_RGBA2BGR) + + # ---------------------------------------------------- + def on_web_loaded(self, ok=True): + """Called every time page load finished""" + if not ok: + self.log("[ERROR] Page failed to load") + self.status.setText("Status: Page load failed") + return + + screen = self.capture_webview() + if screen is None: + self.status.setText("Status: Unable to capture webview") + self.log("Status: Unable to capture webview") + return + + # Nếu đang sau nhấn login, check login failure + if self.login_clicked: + self.log("[INFO] Page loaded after login click. Detecting login failure...") + fail_regions = self.fail_detector.detect(screen) + + if fail_regions: + self.log(f"[FAIL] Login failed detected via template ({len(fail_regions)} regions):") + for folder_name, filename, top_left, bottom_right, score in fail_regions: + self.log(f" - {filename} @ {top_left}-{bottom_right} (score: {score:.2f})") + + self.status.setText("Status: Login failed") + else: + self.log("[SUCCESS] Login seems successful!") + self.status.setText("Status: Login successful") + + self.login_clicked = False + return + + # Nếu là initial page load + self.log("[INFO] Page loaded. Starting template detection...") + regions = self.detector.detect(screen) + + if not regions: + self.status.setText("[WARN] No regions detected") + self.log("[WARN] No regions detected") + return + + self.status.setText(f"[INFO] Detected {len(regions)} valid regions") + self.log(f"[INFO] Detected {len(regions)} valid regions") + QTimer.singleShot(500, lambda: self.autofill_by_detection(regions)) + + # ---------------------------------------------------- + def autofill_by_detection(self, regions): + email = self.account.get("email", "") + password = self.account.get("password", "") + + for folder_name, filename, top_left, bottom_right, score in regions: + self.log(f"[ACTION] {folder_name}: {filename} ({score:.2f})") + label = folder_name.lower() + + if ("user" in label or "email" in label) and email: + self.log(f"[DO] Filling email: {email}") + self.action.write_in_region(top_left, bottom_right, email) + + elif "pass" in label and password: + self.log(f"[DO] Filling password: {'*' * len(password)}") + self.action.write_in_region(top_left, bottom_right, password) + + elif "button" in label or "login" in label: + self.log("[DO] Clicking login button...") + self.login_clicked = True + self.action.click_center_of_region(top_left, bottom_right) + + # ---------------------------------------------------- + def refresh_page(self): + self.log("[INFO] Refreshing page...") + self.web.reload() + + +if __name__ == "__main__": + app = QApplication(sys.argv) + fake_account = {"email": "test@example.com", "password": "123456"} + win = LoginFB(account=fake_account) + win.show() + sys.exit(app.exec_()) diff --git a/gui/main_window.py b/gui/main_window.py index 821a78d..ac95712 100644 --- a/gui/main_window.py +++ b/gui/main_window.py @@ -1,17 +1,14 @@ -# gui/main_window.py - from PyQt5.QtWidgets import QMainWindow, QTabWidget, QMessageBox from gui.tabs.accounts.account_tab import AccountTab from gui.tabs.products.product_tab import ProductTab from gui.tabs.import_tab import ImportTab from gui.tabs.listeds.listed_tab import ListedTab from gui.tabs.settings.settings_tab import SettingsTab -from gui.global_signals import global_signals from gui.core.login_handle_dialog import LoginHandleDialog +from stores.shared_store import SharedStore +from tasks.listed_tasks import start_background_listed +from gui.global_signals import global_signals -# Task runner -from tasks.task_runner import TaskRunner -from tasks.listed_tasks import process_all_listed # Hàm queue mới, 2 dialog class MainWindow(QMainWindow): def __init__(self): @@ -19,7 +16,7 @@ class MainWindow(QMainWindow): self.setWindowTitle("Facebook Marketplace Manager") self.resize(1200, 600) - # --- Tạo QTabWidget --- + # --- Tabs --- self.tabs = QTabWidget() self.account_tab = AccountTab() self.product_tab = ProductTab() @@ -33,54 +30,55 @@ class MainWindow(QMainWindow): self.tabs.addTab(self.import_tab, "Import Data") self.tabs.addTab(self.setting_tab, "Setting") - # Gắn sự kiện khi tab thay đổi self.tabs.currentChanged.connect(self.on_tab_changed) - self.setCentralWidget(self.tabs) - - # Khi mở app thì chỉ load tab đầu tiên (Accounts) self.on_tab_changed(0) - # --- Kết nối signals --- - global_signals.open_login_dialog.connect(self.show_login_dialog) - global_signals.listed_finished.connect(self.on_listed_task_finished) + # # --- Signals --- + # global_signals.listed_finished.connect(self.on_listed_task_finished) + # global_signals.dialog_finished.connect(self.on_dialog_finished) + # global_signals.open_login_dialog.connect(self.show_login_dialog) - # --- 🔥 Khởi chạy queue background khi app mở --- - self.start_background_tasks() + # # --- Start background --- + # start_background_listed() - # ---------------- Dialog handler ---------------- - def show_login_dialog(self, account_id): - """Mở dialog xử lý account_id, modeless để không block MainWindow""" - dialog = LoginHandleDialog(account_id=account_id) + # --- Store opened dialogs --- + self.login_dialogs = [] + + def show_login_dialog(self, account_id, listed_id): + print(account_id, listed_id) + dialog = LoginHandleDialog(account_id=account_id, listed_id=listed_id) dialog.show() # modeless + self.login_dialogs.append(dialog) - # ---------------- Background tasks ---------------- - def start_background_tasks(self): - """Khởi động các background task khi app mở.""" - self.task_runner = TaskRunner() - self.task_runner.start() + # Khi dialog đóng, remove khỏi list và SharedStore + def on_dialog_close(): + if dialog in self.login_dialogs: + self.login_dialogs.remove(dialog) + self.on_dialog_finished(account_id, listed_id) - # Thêm task xử lý listed pending (dùng hàm queue + 2 dialog cùng lúc) - self.task_runner.add_task(process_all_listed) - print("[App] Background task runner started") + dialog.finished.connect(on_dialog_close) + + def on_dialog_finished(self, account_id, listed_id): + """Dialog xong, remove khỏi store""" + store = SharedStore.get_instance() + store.remove(listed_id) + + def on_tab_changed(self, index): + tab = self.tabs.widget(index) + if hasattr(tab, "load_data") and not getattr(tab, "is_loaded", False): + tab.load_data() + tab.is_loaded = True - # ---------------- Listed finished ---------------- def on_listed_task_finished(self): - """Hiển thị thông báo khi listed task hoàn tất""" QMessageBox.information( self, "Auto Listing", "All pending listed items have been processed!" ) - # ---------------- Tab handling ---------------- - def on_tab_changed(self, index): - """Chỉ load nội dung tab khi được active.""" - tab = self.tabs.widget(index) - - # Mỗi tab có thể có hàm load_data() riêng - if hasattr(tab, "load_data"): - # Thêm cờ để tránh load lại nhiều lần không cần thiết - if not getattr(tab, "is_loaded", False): - tab.load_data() - tab.is_loaded = True + def closeEvent(self, event): + """Khi MainWindow đóng, đóng hết tất cả dialog đang mở""" + for dialog in self.login_dialogs[:]: # copy list để tránh lỗi khi remove + dialog.close() + event.accept() diff --git a/gui/tabs/accounts/account_tab.py b/gui/tabs/accounts/account_tab.py index 715410a..019de5f 100644 --- a/gui/tabs/accounts/account_tab.py +++ b/gui/tabs/accounts/account_tab.py @@ -1,3 +1,5 @@ +import os +from functools import partial from PyQt5.QtWidgets import ( QWidget, QVBoxLayout, QTableWidget, QTableWidgetItem, QPushButton, QHBoxLayout, QDialog, QLabel, QLineEdit, QComboBox, QMessageBox, @@ -8,6 +10,10 @@ from PyQt5.QtGui import QFont from PyQt5.QtWidgets import QHeaderView from database.models import Account from .forms.account_form import AccountForm +from config import PROFILES_DIR + +# 👇 import cửa sổ login FB +from gui.handle.login_fb import LoginFB # chỉnh path này theo project của bạn PAGE_SIZE = 10 @@ -17,11 +23,19 @@ class AccountTab(QWidget): self.current_page = 0 layout = QVBoxLayout() - # Add button + # --- Top bar --- + top_layout = QHBoxLayout() self.add_btn = QPushButton("Add Account") - self.add_btn.setMinimumWidth(120) self.add_btn.clicked.connect(self.add_account) - layout.addWidget(self.add_btn) + top_layout.addWidget(self.add_btn) + + # 🆕 Action menu + self.options_btn = QPushButton("Action") + self.options_btn.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) + self.options_btn.setMinimumWidth(80) + self.options_btn.setMaximumWidth(120) + top_layout.addWidget(self.options_btn) + layout.addLayout(top_layout) # Table self.table = QTableWidget() @@ -43,6 +57,7 @@ class AccountTab(QWidget): layout.addLayout(pag_layout) self.setLayout(layout) + self.update_options_menu() self.load_data() def load_data(self): @@ -52,43 +67,71 @@ class AccountTab(QWidget): page_items = accounts[start:end] self.table.setRowCount(len(page_items)) - self.table.setColumnCount(4) - self.table.setHorizontalHeaderLabels(["ID", "Email", "Status", "Actions"]) + self.table.setColumnCount(6) + self.table.setHorizontalHeaderLabels([ + "ID", "Email", "Status", "Profile Exists", "Login At", "Actions" + ]) for i, acc in enumerate(page_items): - # convert sqlite3.Row -> dict acc_dict = {k: acc[k] for k in acc.keys()} self.table.setItem(i, 0, QTableWidgetItem(str(acc_dict["id"]))) self.table.setItem(i, 1, QTableWidgetItem(acc_dict["email"])) self.table.setItem(i, 2, QTableWidgetItem("Active" if acc_dict["is_active"] == 1 else "Inactive")) - # nút menu Actions + # ✅ Check profile folder + folder_name = acc_dict["email"] + profile_path = os.path.join(PROFILES_DIR, folder_name) + profile_status = "True" if os.path.isdir(profile_path) else "False" + self.table.setItem(i, 3, QTableWidgetItem(profile_status)) + + # 🆕 Login At + login_at_value = acc_dict.get("login_at") or "-" + self.table.setItem(i, 4, QTableWidgetItem(str(login_at_value))) + + # Actions btn_menu = QPushButton("Actions") menu = QMenu() + + # 🆕 Login action + action_login = QAction("Login", btn_menu) + action_login.triggered.connect(lambda _, a=acc_dict: self.open_login_window(a)) + menu.addAction(action_login) + + # Edit action_edit = QAction("Edit", btn_menu) action_edit.triggered.connect(lambda _, a=acc_dict: self.edit_account(a)) menu.addAction(action_edit) + # Delete action_delete = QAction("Delete", btn_menu) action_delete.triggered.connect(lambda _, a=acc_dict: self.delete_account(a)) menu.addAction(action_delete) btn_menu.setMenu(menu) - self.table.setCellWidget(i, 3, btn_menu) + self.table.setCellWidget(i, 5, btn_menu) - # Phân bổ column width hợp lý + # Column sizing header = self.table.horizontalHeader() - header.setSectionResizeMode(0, QHeaderView.ResizeToContents) # ID - header.setSectionResizeMode(1, QHeaderView.Stretch) # Email stretch - header.setSectionResizeMode(2, QHeaderView.ResizeToContents) # Status - header.setSectionResizeMode(3, QHeaderView.Fixed) # Actions fixed width - self.table.setColumnWidth(3, 100) # 100px cho Actions + header.setSectionResizeMode(0, QHeaderView.ResizeToContents) + header.setSectionResizeMode(1, QHeaderView.Stretch) + header.setSectionResizeMode(2, QHeaderView.ResizeToContents) + header.setSectionResizeMode(3, QHeaderView.ResizeToContents) + header.setSectionResizeMode(4, QHeaderView.ResizeToContents) + header.setSectionResizeMode(5, QHeaderView.Fixed) + self.table.setColumnWidth(5, 100) - # Enable/disable pagination buttons self.prev_btn.setEnabled(self.current_page > 0) self.next_btn.setEnabled(end < len(accounts)) + # 🆕 Action menu + def update_options_menu(self): + menu = QMenu() + action_reload = QAction("Reload", menu) + action_reload.triggered.connect(lambda: self.load_data()) + menu.addAction(action_reload) + self.options_btn.setMenu(menu) + def add_account(self): form = AccountForm(self) if form.exec_(): @@ -102,7 +145,7 @@ class AccountTab(QWidget): def delete_account(self, account): confirm = QMessageBox.question( - self, "Confirm", f"Delete account {account['email']}?", + self, "Confirm", f"Delete account {account['email']}?", QMessageBox.Yes | QMessageBox.No ) if confirm == QMessageBox.Yes: @@ -116,3 +159,8 @@ class AccountTab(QWidget): def prev_page(self): self.current_page -= 1 self.load_data() + + # 🆕 Mở cửa sổ login FB + def open_login_window(self, account): + self.login_window = LoginFB(account) # truyền account vào nếu class LoginFB có nhận + self.login_window.show() diff --git a/services/action_service.py b/services/action_service.py new file mode 100644 index 0000000..9dd4361 --- /dev/null +++ b/services/action_service.py @@ -0,0 +1,91 @@ +from PyQt5.QtCore import Qt, QTimer + + +class ActionService: + """ + Service mô phỏng hành động người dùng trong QWebEngineView + bằng JavaScript (click, gõ, nhấn phím), KHÔNG chiếm quyền chuột. + """ + + def __init__(self, webview=None, delay=0.05): + """ + webview: QWebEngineView để thao tác + delay: thời gian nghỉ giữa các thao tác (giây) + """ + self.webview = webview + self.delay = delay + + # ---------------------------------------------------------------------- + def _run_js(self, script): + """Chạy JavaScript trên webview""" + if not self.webview: + print("[WARN] Không có webview để chạy JS.") + return + self.webview.page().runJavaScript(script) + + # ---------------------------------------------------------------------- + def click_center_of_region(self, top_left, bottom_right): + """Click bằng JS vào vùng detect""" + if not self.webview: + print("[WARN] Không có webview để click.") + return + + x = (top_left[0] + bottom_right[0]) // 2 + y = (top_left[1] + bottom_right[1]) // 2 + + script = f""" + (function() {{ + const el = document.elementFromPoint({x}, {y}); + if (el) {{ + el.focus(); + el.click(); + console.log("Clicked element:", el.tagName); + }} else {{ + console.log("Không tìm thấy element tại {x},{y}"); + }} + }})(); + """ + self._run_js(script) + + # ---------------------------------------------------------------------- + def write_in_region(self, top_left, bottom_right, text): + """Click + gõ text vào vùng detect bằng JS""" + if not self.webview: + print("[WARN] Không có webview để gõ text.") + return + + x = (top_left[0] + bottom_right[0]) // 2 + y = (top_left[1] + bottom_right[1]) // 2 + safe_text = str(text).replace('"', '\\"') + + script = f""" + (function() {{ + const el = document.elementFromPoint({x}, {y}); + if (el) {{ + el.focus(); + el.value = "{safe_text}"; + const inputEvent = new Event('input', {{ bubbles: true }}); + el.dispatchEvent(inputEvent); + console.log("Gõ text vào:", el.tagName); + }} else {{ + console.log("Không tìm thấy element tại {x},{y}"); + }} + }})(); + """ + self._run_js(script) + + # ---------------------------------------------------------------------- + def press_key(self, key="Enter"): + """Nhấn phím bằng JS""" + if not self.webview: + print("[WARN] Không có webview để nhấn phím.") + return + + script = f""" + (function() {{ + const evt = new KeyboardEvent('keydown', {{ key: '{key}', bubbles: true }}); + document.activeElement && document.activeElement.dispatchEvent(evt); + console.log("Nhấn phím:", '{key}'); + }})(); + """ + self._run_js(script) diff --git a/services/detect_service.py b/services/detect_service.py new file mode 100644 index 0000000..f4ca673 --- /dev/null +++ b/services/detect_service.py @@ -0,0 +1,104 @@ +# services/detect_service.py +import os +import cv2 +import numpy as np +from typing import List + + +class DetectService: + def __init__( + self, + template_dir: str, + target_labels: List[str] = None, + threshold: float = 0.75, + overlap_thresh: float = 0.3, + ): + """ + template_dir: thư mục chứa các template + target_labels: danh sách folder tương ứng các field cần detect, ví dụ ["username", "password", "buttons/login"] + """ + self.template_dir = os.path.abspath(template_dir) + self.threshold = threshold + self.overlap_thresh = overlap_thresh + self.target_labels = [t.lower() for t in target_labels] if target_labels else None + + if not os.path.exists(self.template_dir): + raise FileNotFoundError(f"Template dir not found: {self.template_dir}") + + # ---------------------------------------------------- + def detect(self, screen): + """ + screen: ảnh chụp màn hình (numpy BGR) + return: list [(label, filename, top_left, bottom_right, score)] + """ + regions = [] + + for root, _, files in os.walk(self.template_dir): + rel_path = os.path.relpath(root, self.template_dir).replace("\\", "/").lower() + + # nếu target_labels được định nghĩa thì chỉ detect những folder cần thiết + if self.target_labels and rel_path not in self.target_labels: + continue + + for file in files: + if not file.lower().endswith((".png", ".jpg", ".jpeg")): + continue + + template_path = os.path.join(root, file) + template = cv2.imread(template_path) + if template is None: + continue + + res = cv2.matchTemplate(screen, template, cv2.TM_CCOEFF_NORMED) + loc = np.where(res >= self.threshold) + + for pt in zip(*loc[::-1]): + top_left = (int(pt[0]), int(pt[1])) + bottom_right = ( + int(pt[0] + template.shape[1]), + int(pt[1] + template.shape[0]) + ) + score = float(res[pt[1], pt[0]]) + regions.append((rel_path, file, top_left, bottom_right, score)) + + return self.non_max_suppression(regions) + + # ---------------------------------------------------- + def non_max_suppression(self, regions): + """Giảm trùng vùng detect""" + if not regions: + return [] + + boxes = [] + for label, file, top_left, bottom_right, score in regions: + x1, y1 = top_left + x2, y2 = bottom_right + boxes.append([x1, y1, x2, y2, score, label, file]) + boxes = sorted(boxes, key=lambda x: x[4], reverse=True) + + pick = [] + while boxes: + current = boxes.pop(0) + pick.append(current) + boxes = [ + b for b in boxes + if b[5] != current[5] and self.iou(b, current) < self.overlap_thresh + ] + + return [ + (b[5], b[6], (int(b[0]), int(b[1])), (int(b[2]), int(b[3])), b[4]) + for b in pick + ] + + # ---------------------------------------------------- + def iou(self, boxA, boxB): + """Intersection-over-Union""" + xA = max(boxA[0], boxB[0]) + yA = max(boxA[1], boxB[1]) + xB = min(boxA[2], boxB[2]) + yB = min(boxA[3], boxB[3]) + interArea = max(0, xB - xA) * max(0, yB - yA) + boxAArea = (boxA[2] - boxA[0]) * (boxA[3] - boxA[1]) + boxBArea = (boxB[2] - boxB[0]) * (boxB[3] - boxB[1]) + iou = interArea / float(boxAArea + boxBArea - interArea + 1e-5) + return iou diff --git a/services/profile_service.py b/services/profile_service.py new file mode 100644 index 0000000..80a9913 --- /dev/null +++ b/services/profile_service.py @@ -0,0 +1,142 @@ +# services/profile_service.py +import os +import re +import shutil +import logging +from typing import Optional, List + +logger = logging.getLogger(__name__) +logging.basicConfig(level=logging.INFO) + + +def _sanitize_name(name: str) -> str: + """ + Chuyển tên (email/username) thành dạng an toàn cho filesystem. + Giữ chữ, số, dấu gạch ngang và gạch dưới. + Ví dụ: "user+test@example.com" -> "user-test_example-com" + """ + if not isinstance(name, str): + raise ValueError("Profile name must be a string") + name = name.strip().lower() + # Thay các kí tự '@' và '+' thành '-' + name = name.replace("@", "-at-").replace("+", "-plus-") + # Thay các kí tự không an toàn thành '-' + name = re.sub(r"[^a-z0-9._-]", "-", name) + # Compact nhiều dấu '-' liên tiếp + name = re.sub(r"-{2,}", "-", name) + # Trim dấu '-' đầu cuối + name = name.strip("-") + return name or "profile" + + +class ProfileService: + """ + Service để quản lý thư mục profiles. + Mặc định root folder là ./profiles (tương đối với working dir). + """ + + def __init__(self, profiles_root: Optional[str] = None): + self.profiles_root = os.path.abspath(profiles_root or "profiles") + os.makedirs(self.profiles_root, exist_ok=True) + logger.info("Profiles root: %s", self.profiles_root) + + def get_profile_dirname(self, name: str) -> str: + """Tên folder đã sanitize (chỉ tên folder, không có path)""" + return _sanitize_name(name) + + def get_profile_path(self, name: str) -> str: + """Trả về path tuyệt đối tới folder profile""" + return os.path.join(self.profiles_root, self.get_profile_dirname(name)) + + def exists(self, name: str) -> bool: + """Check folder có tồn tại không""" + return os.path.isdir(self.get_profile_path(name)) + + def create(self, name: str, copy_from: Optional[str] = None, exist_ok: bool = True) -> str: + """ + Tạo folder profile. + - name: email/username + - copy_from: nếu truyền path tới folder mẫu, sẽ copy nội dung từ đó + - exist_ok: nếu True và folder đã tồn tại thì không raise + Trả về path tới folder profile. + """ + path = self.get_profile_path(name) + if os.path.isdir(path): + if exist_ok: + logger.debug("Profile already exists: %s", path) + return path + raise FileExistsError(f"Profile already exists: {path}") + + os.makedirs(path, exist_ok=True) + logger.info("Created profile dir: %s", path) + + if copy_from: + copy_from = os.path.abspath(copy_from) + if os.path.isdir(copy_from): + # copy nội dung bên trong copy_from vào path + for item in os.listdir(copy_from): + s = os.path.join(copy_from, item) + d = os.path.join(path, item) + if os.path.isdir(s): + shutil.copytree(s, d, dirs_exist_ok=True) + else: + shutil.copy2(s, d) + logger.info("Copied profile template from %s to %s", copy_from, path) + else: + logger.warning("copy_from path not found or not a dir: %s", copy_from) + + return path + + def delete(self, name: str, ignore_errors: bool = False) -> None: + """Xóa folder profile (recursive).""" + path = self.get_profile_path(name) + if not os.path.isdir(path): + raise FileNotFoundError(f"Profile not found: {path}") + shutil.rmtree(path, ignore_errors=ignore_errors) + logger.info("Deleted profile dir: %s", path) + + def list_profiles(self) -> List[str]: + """Trả về danh sách tên folder (dirnames) trong profiles_root.""" + try: + return sorted( + [ + d + for d in os.listdir(self.profiles_root) + if os.path.isdir(os.path.join(self.profiles_root, d)) + ] + ) + except FileNotFoundError: + return [] + + # ----------------- Optional: QWebEngineProfile creator ----------------- + def create_qwebengine_profile(self, name: str, parent=None, profile_id: Optional[str] = None): + """ + Tạo và trả về QWebEngineProfile đã cấu hình persistent storage (cookies, local storage, cache). + Yêu cầu PyQt5.QtWebEngineWidgets được cài. + - name: email/username để đặt thư mục lưu + - parent: parent QObject cho QWebEngineProfile (thường là self) + - profile_id: tên id cho profile (tùy chọn) + """ + try: + from PyQt5.QtWebEngineWidgets import QWebEngineProfile + except Exception as e: + raise RuntimeError( + "PyQt5.QtWebEngineWidgets không khả dụng. " + "Không thể tạo QWebEngineProfile." + ) from e + + profile_path = self.get_profile_path(name) + os.makedirs(profile_path, exist_ok=True) + + profile_name = profile_id or self.get_profile_dirname(name) + profile = QWebEngineProfile(profile_name, parent) + profile.setPersistentStoragePath(profile_path) + profile.setCachePath(profile_path) + # Force lưu cookie persist + try: + profile.setPersistentCookiesPolicy(QWebEngineProfile.ForcePersistentCookies) + except Exception: + # Một vài phiên bản PyQt có thể khác tên hằng, bọc try/except để an toàn + pass + logger.info("Created QWebEngineProfile for %s -> %s", name, profile_path) + return profile diff --git a/stores/shared_store.py b/stores/shared_store.py new file mode 100644 index 0000000..fab2488 --- /dev/null +++ b/stores/shared_store.py @@ -0,0 +1,35 @@ +from PyQt5.QtCore import QObject +import threading + +class SharedStore(QObject): + _instance = None + _lock = threading.Lock() + + def __init__(self): + super().__init__() + self._items = [] + self._items_lock = threading.Lock() + + @classmethod + def get_instance(cls): + if not cls._instance: + with cls._lock: + if not cls._instance: + cls._instance = SharedStore() + return cls._instance + + def append(self, item: dict): + with self._items_lock: + self._items.append(item) + + def remove(self, listed_id: int): + with self._items_lock: + self._items = [i for i in self._items if i["listed_id"] != listed_id] + + def size(self) -> int: + with self._items_lock: + return len(self._items) + + def get_items(self) -> list: + with self._items_lock: + return list(self._items) diff --git a/tasks/listed_tasks.py b/tasks/listed_tasks.py index 78cb325..52adac1 100644 --- a/tasks/listed_tasks.py +++ b/tasks/listed_tasks.py @@ -1,138 +1,67 @@ -# tasks/listed_tasks.py - import threading -import queue +import time from database.db import get_connection from services.core.log_service import log_service +from stores.shared_store import SharedStore +from database.models.setting import Setting from gui.global_signals import global_signals stop_event = threading.Event() -dialog_semaphore = None # sẽ khởi tạo dựa trên setting - def stop_listed_task(): - """Dừng toàn bộ task""" stop_event.set() log_service.info("[Task] Stop signal sent") - -def get_settings(): - """Lấy setting từ DB""" +def get_pending_items(limit): conn = get_connection() cursor = conn.cursor() - cursor.execute("SELECT key, value FROM settings") - rows = cursor.fetchall() - conn.close() - return {row["key"]: row["value"] for row in rows} - - -def get_pending_items(limit=1000): - """Lấy danh sách pending listed items""" - conn = get_connection() - cursor = conn.cursor() - cursor.execute(f""" - SELECT l.id, l.account_id, l.product_id, l.listed_at, l.status + cursor.execute(""" + SELECT l.id, l.account_id, l.product_id FROM listed l JOIN accounts a ON l.account_id = a.id - WHERE l.status='pending' - AND a.is_active=1 + WHERE l.status='pending' AND a.is_active=1 ORDER BY l.listed_at ASC - LIMIT {limit} - """) + LIMIT ? + """, (limit,)) rows = cursor.fetchall() conn.close() return rows +def background_loop(): + log_service.info("[Task] Background SharedStore loop started") + store = SharedStore.get_instance() -def process_item(row): - """Xử lý 1 item: mở dialog trên main thread và update DB""" - listed_id, account_id, product_id, listed_at, status = row - log_service.info(f"[Task] Processing listed_id={listed_id}") - - if stop_event.is_set(): - log_service.info("[Task] Stop signal received, skip item") - return - - # --- Đợi semaphore để giới hạn số dialog --- - dialog_semaphore.acquire() - - # --- Tạo event để chờ dialog xong --- - finished_event = threading.Event() - - # Callback khi dialog xong - def on_dialog_finished(finished_account_id): - if finished_account_id == account_id: - finished_event.set() - global_signals.dialog_finished.disconnect(on_dialog_finished) - dialog_semaphore.release() - - global_signals.dialog_finished.connect(on_dialog_finished) - - # --- Emit signal mở dialog trên main thread --- - global_signals.open_login_dialog.emit(account_id) - - # --- Chờ dialog hoàn tất --- - finished_event.wait() - - # --- Update DB --- - conn = get_connection() - cursor = conn.cursor() - cursor.execute("UPDATE listed SET status='listed' WHERE id=?", (listed_id,)) - conn.commit() - conn.close() - - log_service.info(f"[Task] Finished listed_id={listed_id}") - - -def worker_thread(q: queue.Queue): while not stop_event.is_set(): try: - row = q.get(timeout=1) - except queue.Empty: - break - try: - process_item(row) + interval = int(Setting.get("LISTING_INTERVAL_SECONDS", 10)) + max_concurrent = int(Setting.get("MAX_CONCURRENT_LISTING", 2)) + + slots = max_concurrent - store.size() + if slots > 0: + pending_items = get_pending_items(slots) + for row in pending_items: + listed_id, account_id, product_id = row + item = {"listed_id": listed_id, "account_id": account_id} + + # --- Kiểm tra unique trước khi append --- + if not any(x["listed_id"] == listed_id for x in store.get_items()): + store.append(item) + log_service.info(f"[Task] Added listed_id={listed_id} to SharedStore") + + # --- Emit signal để MainWindow mở dialog --- + global_signals.open_login_dialog.emit(account_id, listed_id) + log_service.info(f"[Task] Emitted open_login_dialog for listed_id={listed_id}") + else: + log_service.info(f"[Task] Skipped listed_id={listed_id}, already in SharedStore") + + + time.sleep(interval) except Exception as e: - log_service.error(f"[Task] Exception in worker: {e}") - finally: - q.task_done() + log_service.error(f"[Task] Exception in background_loop: {e}") + log_service.info("[Task] Background SharedStore loop stopped") -def process_all_listed(): - """ - Entry point: tạo queue và 2 worker thread (hoặc theo setting) - """ - log_service.info("[Task] Start processing all listed items") - - settings = get_settings() - max_workers = int(settings.get("MAX_CONCURRENT_LISTING", 2)) - global dialog_semaphore - dialog_semaphore = threading.Semaphore(max_workers) - - # --- Lấy pending items --- - items = get_pending_items(limit=1000) - if not items: - log_service.info("[Task] No pending items") - global_signals.listed_finished.emit() - return - - # --- Tạo queue --- - q = queue.Queue() - for row in items: - q.put(row) - - # --- Tạo worker threads --- - threads = [] - for _ in range(max_workers): - t = threading.Thread(target=worker_thread, args=(q,)) - t.start() - threads.append(t) - - # --- Chờ queue xong --- - q.join() - stop_event.set() - for t in threads: - t.join() - - log_service.info("[Task] All listed items processed") - global_signals.listed_finished.emit() +def start_background_listed(): + t = threading.Thread(target=background_loop, daemon=True) + t.start() + return t diff --git a/templates/buttons/login/Screenshot 2025-10-12 220051.png b/templates/buttons/login/Screenshot 2025-10-12 220051.png new file mode 100644 index 0000000000000000000000000000000000000000..67c454c5886f0f4a12d1b28389058b093b7b2cab GIT binary patch literal 1697 zcmV;S244AzP)Px#1ZP1_K>z@;j|==^1poj532;bRa{vGi!vFvd!vV){sAK>D1~y4VK~#8N?cGmE zn|BX zgW|=*E-NT(tHNLwL6EH-3$+806qAvp*&+Xa&+mC2|FWQ006CtKnH+;AkYCIAVPc9(C1bQT3jn?u`u4}1e%>hAPXPc<(Iy9I6aMk_&F20)~LtyN zn`hshnxBm7O3aupX=gL9{`=-S&UR3ib<6yqaJ_ETP)zG15hbb*cQ*6t-|kdqFx+_T z*pBV6+qLHZBzzr} zMD;lB4s%IL^hTAr6SapcVi8R_LRFH&x@p&)Yh?^$DA60$`sE}3Iv;eqHL-}MtnDin z(UdiRbgbJW%vE{qv{#u*l0v#NVjsJZu8jCys{k5~_%8r~R=tSV=y0h7O05=d2FqG~ zP3Dl^eXsJ<=vbs0j%6(`WYsgCRnK@q8@0c5zQezUyj5_|kA*dCepu8)(s=!qk`giZ zm*yVWzT0)$+00kx=e3-x4qET?R&VwF)L%QRiOt6GN+$ijz1_KAHGPBB-my0V9Vc$) z{;O($nPq~KUe{SSZzD)a{Fn*Pq+fZ2f-=hjoDMI+V0nFXt4r znDLsQ*VSr>o7Wa*w%y+{W7}9Uj+HDd) zE81lQ54p0|>@myexyG;T#&hRI*xS#K_~qsY*sjB2=S$TxRNSWoV=C+AKh6bR1K%+pEk`=|OL_D|*do1F5e1G03=T^uWm@&~Xe=4bs=W5roV-<$hlskvWU+k3TQqTLbbH~?ptOXZJs7XFJzV8ud8R(~8FJG*}k z{UGLE8{IOIM(bayYwvYuPEn)oZFcDROZ1*??AK6C!^Q{XgF{}wKzkw3acqvR3`cKz zST{ZE=w{FQVdZtTUbZkkXa>XD$vN(kJ5@7lCGAvyPSlq_aMsZi-G1S2?3dB+zVX!K z#wEZ&pyTLrMNN2gz1+TMcF0uhZ06OoP^^ZWyR2|1bGxou^u|h7{ZIFA34Cjoa_X<| zZ%s+FX7`Y1KYrz!gY)=R#_Zz^*tqo{!C~BHzp>I<0l5D%3xFH9{-d>-OVK)RpYPQC z<;ESPb}#p-GT*b`vIoEkAkfiTmWo=dw?CQ}YmJMv<{hL!8zIoqVhXx(*IX0D>2NKA zK(J6l2Y?nu4IKaiia-Z|fFRHTARq{I00;;I9RLD?KnH+;a0U81o?lo10B9y&S?vn+ z4|`lg0DvarmDR41uRcCw4*>w0j8|5>0{vPtqEGw$)@cR+oVL#|M|3S2v4`D(E=g0@ zqdG&T13=U1IwO7eh4-qRuC-iDbE^d{t`)WNtfUuN-)R7VH|RnmkqqARzt+I+1kY{dA%|00000NkvXXu0mjfLvB8b literal 0 HcmV?d00001 diff --git a/templates/login_fail/Screenshot 2025-10-14 at 16.23.16.png b/templates/login_fail/Screenshot 2025-10-14 at 16.23.16.png new file mode 100644 index 0000000000000000000000000000000000000000..bd6f62a1718024984ddcfc7c46a83fc9faf7b77b GIT binary patch literal 28604 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 literal 0 HcmV?d00001 diff --git a/templates/password/Screenshot 2025-10-14 at 15.53.32.png b/templates/password/Screenshot 2025-10-14 at 15.53.32.png new file mode 100644 index 0000000000000000000000000000000000000000..1d466b9759664afe2e953233a379cb31a06aaf17 GIT binary patch literal 3800 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 literal 0 HcmV?d00001 diff --git a/templates/password/tpl_password.png b/templates/password/tpl_password.png new file mode 100644 index 0000000000000000000000000000000000000000..a0fc411949a772edd8d8a497eb4921af22fbec98 GIT binary patch literal 1721 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 literal 0 HcmV?d00001 diff --git a/templates/username/Screenshot 2025-10-14 at 13.45.45.png b/templates/username/Screenshot 2025-10-14 at 13.45.45.png new file mode 100644 index 0000000000000000000000000000000000000000..7620a00b2c34bbf25272be750fb98e8b7fb97d34 GIT binary patch literal 5392 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 Z7QRlV%PXhshd4Pi)7LT9uGDml_#ZY-tN#E1 literal 0 HcmV?d00001 diff --git a/templates/username/tpl_username.png b/templates/username/tpl_username.png new file mode 100644 index 0000000000000000000000000000000000000000..7e8ee62174fd540b2c0f7056ca0fe24b44d0f2cc GIT binary patch literal 2761 zcmai$c{J4h7sp403S$e2!DJ~(hLojf^d0*$6jRwrJj6_vvDBCdV@(p0bu3Mis3vQZ zb(qL*LY|2jBw5B|%UGW2`JMB7&iVcG`{T3RbME=v&%NjKdEZw8(%MAup!7iy2qb7` z3fO@_T!EbZd468be1{80Brp8MXc zeNzKg>!&HBc;^Xb!gbkodBR`&C^%gQ*4*9pDe6dzKs|RW@L7qm>H&=rxuGTh@FB#BnDNOHuD&+rhGxRPC3mJ= z4?!grp)b!C$p!PJH~Uuw)aw?f?K}l1JA`Q~wgA z^zlqowkPxY-NU9)3f2G_ygxBjKEqrA;+lcVhC@;-TV_v^E0v%{xK`BW0Ww`X{PhuL zLYre4g|Oh45bra-ED@Gws>r0?e5i8n?87D2$grqL|A(|CmVEZ7Tr=8<69Ayz@o!!* zT0*?j1GqK73={~2LbE&{iiy<8+|P}tO-nKVaW(v|EH^n*F;+Wjs8p#?#2pQfETn4a zRD(*1Vz?|nW(E*^QG*7qZ9jCOHu9|eY2_kr#T}|>uXkjtHy!_Y7%#O3Tf8e4t9-gf z6+NE!1GAzKAk^MJ5^|U*UnsC|Ue@cKfV%aruta@TlPBi-920EpL_QqDpPzROL#g4*deC0jxe)JT^3xfi{zW48OBCE<4fXZG zhb}IPs8+krQZE4@NF9oUy8(~0(a)xvX)DN*6`q6FON zi*={Cd?3SDwlQ!-xfn0=MJKJM+~+xU9wUy(5vq7!Y51uj@AEWYrorv+sbn>_lXwSM zw)In?msIcEHuFQsri>TFv~;YC9!kZ&=7ZDtN_)*cbjW#R!l9krnr)Q{<`DS+a9Nq<>HK4Mt>MnM#4+>&hnD4 zJF(f4{O@#@EM)5m`0Y0(sD}1BAtBGEz*pEzh2+)9HGkvJF9vWBPSx-iHj4VcqdXgJ zyTWy7Ba53ylLhA7uLV2h<_uMivX3Rdn<}!ZM;Bi;^w%AJ&A*$zroA6IZWsH`N4(N} ztbdq6T=HWbF(zd#`|8QbwzKiug3x0&8Ub_nKYgfZ`7&s-`7Lc)%cHL8E}b6d(1D{g zuMQq}Ief({+$J8Ib=hHw)|V&|S=j5`vsp)g&qX@^q2uik5TatsMS<4ad6S>Rak?O@ z!!GM>DV(06`E>ysN!g))VdBOSI*h zX@|@&40T9l^Ql@pmN$ZoNL`?X{mNIa_7o3dU!jb=nzprofw61UsuC`9uL`fmEe+Ex zA({@Bc3mWC$JciINd>+QTfK2?ByOIlSw?hb+KNkeVhoB6C!cIuuM*%9uKLnHacl)u zqfB#yt4Y55`!YQ}qAri~3fC9njg8}0u|N8BW{`0X^9;`_!HVg7XOA)l=AmB>>5DSW zFOj@%jcldsZX<(NFO|#O!63G%h)LXAufP&CMGGr!T8DL?5r{ z6%5P@X>b!ptZ0sIW7-*6JHMzmlarWdJL}LS%y?M$I6%zcJlPt)*{*3Uz|LVuJK>Vi zRR5rARs1Xu@=+JvV?}{?W-S|{ujxtEJNnE*PF+ga9diChmgB7AJhrHwVY#kfEJK4jB1s_3W&YEeNi{st-ikd z{Z1-y>e9*4IK8%0yF0f$N8h*|RoC9^?yO$r7jJ`!Wv=W05(pC7$;|X`uqto}-HCkJ>-HK2$Mu(*N}<=ec0+$7I&?B3{Sn=P{aUw@T3U?)3Cr!`m} z++L@&`2)8U7PPFdQ7Cv}K-ZQo6%CJT*_F8ef>${B!w<2ABczwu@xH{dc-WG@$D-yv zZs|#nr_(MeNq9w^^5eB78L3GoTH60M_W!cY|7QS>YhCceJ1Z;8>e>IH zk11!(1F0%+cdbveA}FScqU-NC5QA3yEVdj$S@F=K?d%%0p@x6{gCy~luiz~2$UFx< z+TliUIANuV{N4i%2Y@^3)ntKG6>wz1uaJbeRY?me3C}LmQoH5Dk^9h5lD{CSA=)A$ z3y(rSDvHh@;G8$XVk#i*)Xs6`M#~i4