# password_change_dialog.py from PySide6.QtWidgets import ( QDialog, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton, QMessageBox, QCheckBox, QFrame, QProgressBar, QTextEdit, QSizePolicy, QApplication ) from PySide6.QtCore import Qt, QTimer, QObject, Signal, QThread, Slot from PySide6.QtGui import QFont import logging import time import threading class PasswordChangeDialog(QDialog): # 시그널 정의 code_send_result = Signal(dict, str) # (result, email) code_send_error = Signal(str) # (error_message) password_change_result = Signal(dict, str) # (result, email) password_change_error = Signal(str) # (error_message) def __init__(self, logger, supabase_manager, parent=None, required=False): super().__init__(parent) self.logger = logger self.supabase_manager = supabase_manager self.email_verified = False self.required = required # 필수 변경 모드 (취소 불가능) # 시그널 연결 self.code_send_result.connect(self._handle_send_code_result) self.code_send_error.connect(self._handle_send_code_error) self.password_change_result.connect(self._handle_password_change_result) self.password_change_error.connect(self._handle_password_change_error) self.setWindowTitle("비밀번호 변경") # 창 크기 설정 window_width = 500 window_height = 600 self.resize(window_width, window_height) # 화면 중앙에 위치시키기 if parent: # 부모 창이 있으면 부모 창 중앙 (약간 위로) parent_geometry = parent.geometry() x = parent_geometry.x() + (parent_geometry.width() - window_width) // 2 y = parent_geometry.y() + (parent_geometry.height() - window_height) // 2 - (window_height // 3) else: # 부모 창이 없으면 화면 중앙 (사용 가능한 영역 기준) screen = QApplication.primaryScreen() if screen: # availableGeometry를 사용하여 작업 표시줄 등을 제외한 실제 사용 가능한 영역 사용 available_geometry = screen.availableGeometry() x = available_geometry.x() + (available_geometry.width() - window_width) // 2 # 화면 중앙보다 약간 위로 배치 (y 좌표에서 창 높이의 30% 정도 위로) y = available_geometry.y() + (available_geometry.height() - window_height) // 2 - (window_height // 3) else: # 폴백: 기본 위치 x = 100 y = 10 self.move(x, y) self.setModal(True) # 필수 변경 모드인 경우 창 닫기 버튼 비활성화 if self.required: self.setWindowFlags(Qt.Dialog | Qt.WindowTitleHint | Qt.WindowCloseButtonHint) self.init_ui() def init_ui(self): layout = QVBoxLayout(self) layout.setContentsMargins(30, 30, 30, 30) layout.setSpacing(20) # 타이틀 title_label = QLabel("비밀번호 변경") title_label.setAlignment(Qt.AlignCenter) title_label.setStyleSheet("font-size: 24px; font-weight: bold; color: #333; margin-bottom: 10px;") layout.addWidget(title_label) # 안내 문구 if self.required: info_label = QLabel("보안을 위해 비밀번호를 반드시 변경해야 합니다.\n이메일 인증을 완료한 후 새 비밀번호를 설정하세요.") else: info_label = QLabel("보안을 위해 이메일 인증을 완료한 후 새 비밀번호를 설정하세요.") info_label.setAlignment(Qt.AlignCenter) info_label.setStyleSheet("font-size: 13px; color: #666; margin-bottom: 20px;") info_label.setWordWrap(True) layout.addWidget(info_label) # 이메일 입력 영역 email_label = QLabel("이메일:") email_label.setStyleSheet("font-size: 14px; font-weight: bold; color: #333;") layout.addWidget(email_label) email_layout = QHBoxLayout() self.email_input = QLineEdit() self.email_input.setPlaceholderText("이메일 주소를 입력하세요") self.email_input.setStyleSheet(""" QLineEdit { padding: 12px; font-size: 14px; border: 2px solid #ddd; border-radius: 8px; background-color: #fafafa; } QLineEdit:focus { border-color: #1877f2; background-color: white; } QLineEdit:disabled { background-color: #e9ecef; color: #6c757d; } """) email_layout.addWidget(self.email_input) self.send_code_button = QPushButton("인증코드 전송") self.send_code_button.setStyleSheet(""" QPushButton { background-color: #1877f2; color: white; border: none; padding: 12px 20px; border-radius: 8px; font-size: 13px; font-weight: bold; min-width: 120px; } QPushButton:hover { background-color: #166fe5; } QPushButton:disabled { background-color: #ccc; color: #888; } """) self.send_code_button.clicked.connect(self.handle_send_verification_code) email_layout.addWidget(self.send_code_button) layout.addLayout(email_layout) # 인증 코드 입력 영역 verification_label = QLabel("인증 코드:") verification_label.setStyleSheet("font-size: 14px; font-weight: bold; color: #333;") layout.addWidget(verification_label) verification_layout = QHBoxLayout() self.verification_code_input = QLineEdit() self.verification_code_input.setPlaceholderText("이메일로 받은 인증 코드를 입력하세요") self.verification_code_input.setMaxLength(50) self.verification_code_input.setEnabled(False) self.verification_code_input.setStyleSheet(""" QLineEdit { padding: 12px; font-size: 14px; border: 2px solid #ddd; border-radius: 8px; background-color: #e9ecef; } QLineEdit:focus { border-color: #1877f2; background-color: white; } QLineEdit:enabled { background-color: #fafafa; } """) verification_layout.addWidget(self.verification_code_input) self.verify_button = QPushButton("인증 확인") self.verify_button.setEnabled(False) self.verify_button.setStyleSheet(""" QPushButton { background-color: #28a745; color: white; border: none; padding: 12px 20px; border-radius: 8px; font-size: 13px; font-weight: bold; min-width: 120px; } QPushButton:hover { background-color: #218838; } QPushButton:disabled { background-color: #ccc; color: #888; } """) self.verify_button.clicked.connect(self.handle_verify_code) verification_layout.addWidget(self.verify_button) layout.addLayout(verification_layout) # 인증 상태 표시 self.verification_status_label = QLabel("") self.verification_status_label.setStyleSheet("font-size: 12px; margin-top: 5px;") layout.addWidget(self.verification_status_label) # 로딩 상태 표시 (인증코드 전송 중) self.loading_label = QLabel("") self.loading_label.setStyleSheet("font-size: 12px; color: #1877f2; margin-top: 5px;") self.loading_label.setAlignment(Qt.AlignCenter) layout.addWidget(self.loading_label) # 구분선 separator = QFrame() separator.setFrameShape(QFrame.HLine) separator.setStyleSheet("background-color: #ddd; max-height: 1px;") layout.addWidget(separator) # 새 비밀번호 입력 영역 (인증 완료 전까지 비활성화) new_password_label = QLabel("새 비밀번호:") new_password_label.setStyleSheet("font-size: 14px; font-weight: bold; color: #333;") layout.addWidget(new_password_label) new_password_layout = QHBoxLayout() self.new_password_input = QLineEdit() self.new_password_input.setPlaceholderText("새 비밀번호를 입력하세요") self.new_password_input.setEchoMode(QLineEdit.Password) self.new_password_input.setEnabled(False) self.new_password_input.setStyleSheet(""" QLineEdit { padding: 12px; font-size: 14px; border: 2px solid #ddd; border-radius: 8px; background-color: #e9ecef; } QLineEdit:focus { border-color: #1877f2; background-color: white; } QLineEdit:enabled { background-color: #fafafa; } """) self.new_password_input.textChanged.connect(self.check_password_strength) new_password_layout.addWidget(self.new_password_input) self.new_show_password_checkbox = QCheckBox("보기") self.new_show_password_checkbox.setEnabled(False) self.new_show_password_checkbox.stateChanged.connect(self.toggle_new_password_visibility) new_password_layout.addWidget(self.new_show_password_checkbox) layout.addLayout(new_password_layout) # 비밀번호 강도 표시기 strength_label = QLabel("비밀번호 강도:") strength_label.setStyleSheet("font-size: 13px; color: #666;") layout.addWidget(strength_label) self.strength_bar = QProgressBar() self.strength_bar.setMinimum(0) self.strength_bar.setMaximum(100) self.strength_bar.setValue(0) self.strength_bar.setStyleSheet(""" QProgressBar { border: 1px solid #ddd; border-radius: 4px; text-align: center; height: 20px; } QProgressBar::chunk { background-color: #ff4444; border-radius: 3px; } """) layout.addWidget(self.strength_bar) # 비밀번호 요구사항 표시 self.requirements_text = QTextEdit() self.requirements_text.setReadOnly(True) self.requirements_text.setMaximumHeight(100) self.requirements_text.setStyleSheet(""" QTextEdit { border: 1px solid #ddd; border-radius: 4px; background-color: #f9f9f9; padding: 8px; font-size: 12px; } """) self.requirements_text.setPlainText( "비밀번호 요구사항:\n" "• 최소 8자 이상\n" "• 영문 대문자 포함\n" "• 영문 소문자 포함\n" "• 숫자 포함\n" "• 특수문자 포함 (!@#$%^&*(),.?\":{}|<>)" ) layout.addWidget(self.requirements_text) # 새 비밀번호 확인 confirm_password_label = QLabel("새 비밀번호 확인:") confirm_password_label.setStyleSheet("font-size: 14px; font-weight: bold; color: #333;") layout.addWidget(confirm_password_label) confirm_password_layout = QHBoxLayout() self.confirm_password_input = QLineEdit() self.confirm_password_input.setPlaceholderText("새 비밀번호를 다시 입력하세요") self.confirm_password_input.setEchoMode(QLineEdit.Password) self.confirm_password_input.setEnabled(False) self.confirm_password_input.setStyleSheet(""" QLineEdit { padding: 12px; font-size: 14px; border: 2px solid #ddd; border-radius: 8px; background-color: #e9ecef; } QLineEdit:focus { border-color: #1877f2; background-color: white; } QLineEdit:enabled { background-color: #fafafa; } """) self.confirm_password_input.textChanged.connect(self.check_password_match) confirm_password_layout.addWidget(self.confirm_password_input) self.confirm_show_password_checkbox = QCheckBox("보기") self.confirm_show_password_checkbox.setEnabled(False) self.confirm_show_password_checkbox.stateChanged.connect(self.toggle_confirm_password_visibility) confirm_password_layout.addWidget(self.confirm_show_password_checkbox) layout.addLayout(confirm_password_layout) # 비밀번호 일치 상태 표시 self.match_label = QLabel("") self.match_label.setStyleSheet("font-size: 12px; margin-top: 5px;") layout.addWidget(self.match_label) # 버튼 영역 button_layout = QHBoxLayout() button_layout.setSpacing(15) self.cancel_button = QPushButton("취소") self.cancel_button.setStyleSheet(""" QPushButton { background-color: #f8f9fa; color: #333; border: 1px solid #ccc; padding: 12px 24px; border-radius: 6px; font-size: 14px; font-weight: bold; min-width: 100px; } QPushButton:hover { background-color: #e9ecef; border-color: #adb5bd; } QPushButton:pressed { background-color: #dee2e6; } QPushButton:disabled { background-color: #e9ecef; color: #6c757d; } """) self.cancel_button.clicked.connect(self.reject) # 필수 변경 모드인 경우 취소 버튼 비활성화 if self.required: self.cancel_button.setEnabled(False) self.cancel_button.setToolTip("비밀번호 변경이 필수입니다. 취소할 수 없습니다.") self.change_button = QPushButton("변경") self.change_button.setEnabled(False) self.change_button.setStyleSheet(""" QPushButton { background-color: #1877f2; color: white; border: none; padding: 12px 24px; border-radius: 6px; font-size: 14px; font-weight: bold; min-width: 100px; } QPushButton:hover { background-color: #166fe5; } QPushButton:pressed { background-color: #1462d0; } QPushButton:disabled { background-color: #ccc; color: #888; } """) self.change_button.clicked.connect(self.handle_password_change) button_layout.addStretch() button_layout.addWidget(self.cancel_button) button_layout.addWidget(self.change_button) layout.addLayout(button_layout) self.setLayout(layout) def handle_send_verification_code(self): """인증 코드 전송 처리""" email = self.email_input.text().strip() if not email: QMessageBox.warning(self, "입력 오류", "이메일을 입력해주세요.") return # 이메일 형식 검증 if "@" not in email or "." not in email.split("@")[1]: QMessageBox.warning(self, "입력 오류", "올바른 이메일 형식을 입력해주세요.") return # 버튼 비활성화 및 로딩 상태 표시 self.send_code_button.setEnabled(False) self.email_input.setEnabled(False) self.loading_label.setText("인증 코드를 전송 중입니다...") self.loading_label.setStyleSheet("font-size: 12px; color: #1877f2; margin-top: 5px; font-weight: bold;") # UI 업데이트를 위해 즉시 처리 QTimer.singleShot(0, lambda: self._send_verification_code_threaded(email)) def _send_verification_code_threaded(self, email: str): """비동기로 인증 코드 전송 처리""" def send_code(): try: # 인증 코드 전송 result = self.supabase_manager.send_password_change_verification_email(email) # 결과를 메인 스레드로 전달 (Signal 사용) self.code_send_result.emit(result, email) except Exception as e: error_message = f"인증 코드 전송 중 오류가 발생했습니다: {str(e)}" self.logger.log(error_message, level=logging.ERROR, exc_info=True) # 에러를 메인 스레드로 전달 (Signal 사용) self.code_send_error.emit(error_message) # 백그라운드 스레드에서 실행 thread = threading.Thread(target=send_code, daemon=True) thread.start() @Slot(dict, str) def _handle_send_code_result(self, result: dict, email: str): """인증 코드 전송 결과 처리""" # 로딩 상태 제거 (즉시) self.loading_label.setText("") self.loading_label.setStyleSheet("font-size: 12px; color: #1877f2; margin-top: 5px;") if result["success"]: QMessageBox.information(self, "전송 완료", f"인증 코드가 '{email}'로 전송되었습니다.\n\n" "이메일을 확인하여 인증 코드를 입력해주세요.\n" "이메일이 도착하지 않으면 스팸 폴더도 확인해주세요.") self.verification_code_input.setEnabled(True) self.verify_button.setEnabled(True) self.verification_status_label.setText("인증 코드를 입력해주세요.") self.verification_status_label.setStyleSheet("font-size: 12px; color: #666; margin-top: 5px;") else: QMessageBox.warning(self, "전송 실패", result["message"]) # 버튼 재활성화 self.send_code_button.setEnabled(True) self.email_input.setEnabled(True) @Slot(str) def _handle_send_code_error(self, error_message: str): """인증 코드 전송 오류 처리""" # 로딩 상태 제거 self.loading_label.setText("") self.loading_label.setStyleSheet("font-size: 12px; color: #1877f2; margin-top: 5px;") QMessageBox.critical(self, "오류", error_message) # 버튼 재활성화 self.send_code_button.setEnabled(True) self.email_input.setEnabled(True) def handle_verify_code(self): """인증 코드 검증 처리""" email = self.email_input.text().strip() code = self.verification_code_input.text().strip() if not code: QMessageBox.warning(self, "입력 오류", "인증 코드를 입력해주세요.") return # 버튼 비활성화 self.verify_button.setEnabled(False) try: # 인증 코드 검증 result = self.supabase_manager.verify_password_change_token(email, code) if result["success"]: self.email_verified = True self.verification_status_label.setText("✓ 이메일 인증이 완료되었습니다.") self.verification_status_label.setStyleSheet("font-size: 12px; color: #28a745; margin-top: 5px; font-weight: bold;") # 비밀번호 입력 필드 활성화 self.new_password_input.setEnabled(True) self.confirm_password_input.setEnabled(True) self.new_show_password_checkbox.setEnabled(True) self.confirm_show_password_checkbox.setEnabled(True) # 인증 코드 입력 필드 비활성화 self.verification_code_input.setEnabled(False) self.verify_button.setEnabled(False) QMessageBox.information(self, "인증 완료", "이메일 인증이 완료되었습니다.\n이제 새 비밀번호를 입력할 수 있습니다.") else: QMessageBox.warning(self, "인증 실패", result["message"]) self.verification_status_label.setText("✗ 인증 실패: " + result["message"]) self.verification_status_label.setStyleSheet("font-size: 12px; color: #dc3545; margin-top: 5px;") except Exception as e: error_message = f"인증 코드 검증 중 오류가 발생했습니다: {str(e)}" self.logger.log(error_message, level=logging.ERROR, exc_info=True) QMessageBox.critical(self, "오류", error_message) finally: self.verify_button.setEnabled(True) def toggle_new_password_visibility(self, state): """새 비밀번호 보기/숨기기 토글""" if state == 2: # Qt.Checked self.new_password_input.setEchoMode(QLineEdit.Normal) else: self.new_password_input.setEchoMode(QLineEdit.Password) def toggle_confirm_password_visibility(self, state): """비밀번호 확인 보기/숨기기 토글""" if state == 2: # Qt.Checked self.confirm_password_input.setEchoMode(QLineEdit.Normal) else: self.confirm_password_input.setEchoMode(QLineEdit.Password) def check_password_strength(self): """비밀번호 강도 검사""" password = self.new_password_input.text() if not password: self.strength_bar.setValue(0) self.strength_bar.setStyleSheet(""" QProgressBar { border: 1px solid #ddd; border-radius: 4px; text-align: center; height: 20px; } QProgressBar::chunk { background-color: #ff4444; border-radius: 3px; } """) self.update_change_button_state() return # 비밀번호 강도 계산 strength = 0 if len(password) >= 8: strength += 20 if any(c.isupper() for c in password): strength += 20 if any(c.islower() for c in password): strength += 20 if any(c.isdigit() for c in password): strength += 20 if any(c in "!@#$%^&*(),.?\":{}|<>" for c in password): strength += 20 self.strength_bar.setValue(strength) # 강도에 따른 색상 변경 if strength < 40: color = "#ff4444" # 빨간색 elif strength < 80: color = "#ff8c00" # 주황색 else: color = "#00aa00" # 초록색 self.strength_bar.setStyleSheet(f""" QProgressBar {{ border: 1px solid #ddd; border-radius: 4px; text-align: center; height: 20px; }} QProgressBar::chunk {{ background-color: {color}; border-radius: 3px; }} """) self.update_change_button_state() def check_password_match(self): """비밀번호 일치 확인""" new_password = self.new_password_input.text() confirm_password = self.confirm_password_input.text() if not confirm_password: self.match_label.setText("") elif new_password == confirm_password: self.match_label.setText("✓ 비밀번호가 일치합니다") self.match_label.setStyleSheet("font-size: 12px; color: #00aa00; margin-top: 5px;") else: self.match_label.setText("✗ 비밀번호가 일치하지 않습니다") self.match_label.setStyleSheet("font-size: 12px; color: #ff4444; margin-top: 5px;") self.update_change_button_state() def update_change_button_state(self): """변경 버튼 활성화/비활성화 상태 업데이트""" if not self.email_verified: self.change_button.setEnabled(False) return new_password = self.new_password_input.text() confirm_password = self.confirm_password_input.text() # 모든 필드가 채워져 있고, 비밀번호가 일치하며, 강도가 충분한지 확인 is_valid = ( new_password.strip() != "" and confirm_password.strip() != "" and new_password == confirm_password and self.strength_bar.value() >= 100 # 모든 조건을 만족해야 100 ) self.change_button.setEnabled(is_valid) def handle_password_change(self): """비밀번호 변경 처리""" email = self.email_input.text().strip() new_password = self.new_password_input.text().strip() confirm_password = self.confirm_password_input.text().strip() # 기본 검증 if not self.email_verified: QMessageBox.warning(self, "인증 오류", "이메일 인증을 먼저 완료해주세요.") return if not new_password or not confirm_password: QMessageBox.warning(self, "입력 오류", "모든 필드를 입력해주세요.") return if new_password != confirm_password: QMessageBox.warning(self, "입력 오류", "새 비밀번호가 일치하지 않습니다.") return # 비밀번호 강도 검증 validation_result = self.supabase_manager.validate_password_strength(new_password) if not validation_result["is_valid"]: error_message = "비밀번호 보안 요구사항을 만족하지 않습니다:\n\n" for error in validation_result["errors"]: error_message += f"• {error}\n" QMessageBox.warning(self, "비밀번호 보안 오류", error_message) return # 버튼 비활성화 및 로딩 상태 표시 self.change_button.setEnabled(False) self.cancel_button.setEnabled(False) self.loading_label.setText("비밀번호를 변경하는 중입니다...") self.loading_label.setStyleSheet("font-size: 12px; color: #1877f2; margin-top: 5px; font-weight: bold;") # UI 업데이트를 위해 즉시 처리 QTimer.singleShot(0, lambda: self._change_password_threaded(email, new_password)) def _change_password_threaded(self, email: str, new_password: str): """비동기로 비밀번호 변경 처리""" def change_password(): try: # 비밀번호 변경 시도 result = self.supabase_manager.change_password_with_email_verification(email, new_password) # 결과를 메인 스레드로 전달 (Signal 사용) self.password_change_result.emit(result, email) except Exception as e: error_message = f"비밀번호 변경 중 오류가 발생했습니다: {str(e)}" self.logger.log(error_message, level=logging.ERROR, exc_info=True) # 에러를 메인 스레드로 전달 (Signal 사용) self.password_change_error.emit(error_message) # 백그라운드 스레드에서 실행 thread = threading.Thread(target=change_password, daemon=True) thread.start() @Slot(dict, str) def _handle_password_change_result(self, result: dict, email: str): """비밀번호 변경 결과 처리""" # 로딩 상태 제거 self.loading_label.setText("") if result["success"]: self.logger.log("비밀번호 변경 성공", level=logging.INFO) QMessageBox.information(self, "변경 완료", result["message"]) self.accept() else: self.logger.log(f"비밀번호 변경 실패: {result.get('error', 'Unknown')}", level=logging.WARNING) QMessageBox.warning(self, "변경 실패", result["message"]) # 버튼 재활성화 self.change_button.setEnabled(True) if not self.required: self.cancel_button.setEnabled(True) @Slot(str) def _handle_password_change_error(self, error_message: str): """비밀번호 변경 오류 처리""" # 로딩 상태 제거 self.loading_label.setText("") QMessageBox.critical(self, "오류", error_message) # 버튼 재활성화 self.change_button.setEnabled(True) if not self.required: self.cancel_button.setEnabled(True) def closeEvent(self, event): """창 닫기 이벤트 처리""" if self.required: # 필수 변경 모드인 경우 창 닫기 방지 QMessageBox.warning(self, "비밀번호 변경 필수", "보안을 위해 비밀번호 변경이 필수입니다.\n비밀번호를 변경한 후에만 계속 진행할 수 있습니다.") event.ignore() else: event.accept()