AutoPercenty3/password_change_dialog.py

722 lines
29 KiB
Python

# 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()