AutoPercenty3/signup_dialog.py

504 lines
24 KiB
Python

from PySide6.QtWidgets import (
QDialog, QVBoxLayout, QLabel, QTextEdit, QCheckBox, QLineEdit,
QPushButton, QMessageBox, QScrollArea, QWidget, QTabWidget, QGridLayout, QProgressBar
)
from PySide6.QtCore import Qt, QTimer, QTime
from PySide6.QtGui import QFont
from datetime import datetime, timezone
from collapsible_announcement import CollapsibleAnnouncement
import webbrowser
import logging
class SignupDialog(QDialog):
def __init__(self, logger, supabase_manager, parent=None):
super().__init__(parent)
self.logger = logger
self.supabase_manager = supabase_manager
self.setWindowTitle("회원가입")
self.setFixedSize(700, 850)
self.setup_ui()
self.load_announcements_content()
def setup_ui(self):
self.layout = QVBoxLayout(self)
self.layout.setContentsMargins(20, 20, 20, 20)
self.layout.setSpacing(15)
# 회원가입 타이틀
title_label = QLabel("회원가입")
title_label.setAlignment(Qt.AlignCenter)
title_label.setStyleSheet("font-size: 20px; font-weight: bold;")
self.layout.addWidget(title_label)
# 공지사항 아코디언 영역 (ScrollArea)
announcement_container = QWidget()
self.ann_layout = QVBoxLayout(announcement_container)
self.ann_layout.setSpacing(10)
announcement_container.setLayout(self.ann_layout)
scroll_area = QScrollArea()
scroll_area.setWidgetResizable(True)
scroll_area.setWidget(announcement_container)
scroll_area.setFixedHeight(300)
self.layout.addWidget(QLabel("공지사항"))
self.layout.addWidget(scroll_area)
# 탭 위젯 생성 (두 개의 탭: 개인정보 동의, 프로그램 라이센스 동의)
self.tab_widget = QTabWidget()
self.layout.addWidget(self.tab_widget)
# 개인정보 동의 탭 구성
self.privacy_tab = QWidget()
privacy_layout = QVBoxLayout(self.privacy_tab)
privacy_layout.setSpacing(10)
privacy_info = QTextEdit()
privacy_info.setReadOnly(True)
privacy_info.setFixedHeight(130)
privacy_content = self.supabase_manager.get_privacy_content()
privacy_info.setHtml(privacy_content if privacy_content else "개인정보 동의 내용이 없습니다.")
privacy_layout.addWidget(QLabel("개인정보 수집 및 이용 동의"))
privacy_layout.addWidget(privacy_info)
self.privacy_checkbox = QCheckBox("위 내용을 모두 읽고 동의합니다.")
privacy_layout.addWidget(self.privacy_checkbox)
self.tab_widget.addTab(self.privacy_tab, "개인정보 동의")
# 프로그램 라이센스 동의 탭 구성
self.license_tab = QWidget()
license_layout = QVBoxLayout(self.license_tab)
license_layout.setSpacing(10)
license_info = QTextEdit()
license_info.setReadOnly(True)
license_info.setFixedHeight(130)
program_content = self.supabase_manager.get_program_license_content()
license_info.setHtml(program_content if program_content else "프로그램 라이센스 내용이 없습니다.")
license_layout.addWidget(QLabel("프로그램 라이센스 동의"))
license_layout.addWidget(license_info)
self.program_license_checkbox = QCheckBox("위 내용을 모두 읽고 동의합니다.")
license_layout.addWidget(self.program_license_checkbox)
self.tab_widget.addTab(self.license_tab, "프로그램 라이센스 동의")
# 처음에는 라이센스 탭을 비활성화
self.tab_widget.setTabEnabled(1, False)
# 회원가입 정보 입력 영역
input_widget = QWidget()
grid = QGridLayout(input_widget)
grid.setSpacing(10)
# Row 0: 이메일, 비밀번호
email_label = QLabel("이메일 (필수):")
self.email_input = QLineEdit()
self.email_input.setPlaceholderText("이메일 입력")
grid.addWidget(email_label, 0, 0)
grid.addWidget(self.email_input, 0, 1)
password_label = QLabel("비밀번호 (필수):")
self.password_input = QLineEdit()
self.password_input.setPlaceholderText("비밀번호 입력")
self.password_input.setEchoMode(QLineEdit.Password)
grid.addWidget(password_label, 1, 0)
grid.addWidget(self.password_input, 1, 1)
verify_label = QLabel("비밀번호 확인 (필수):")
self.verify_password_input = QLineEdit()
self.verify_password_input.setPlaceholderText("비밀번호 확인 입력")
self.verify_password_input.setEchoMode(QLineEdit.Password)
grid.addWidget(verify_label, 1, 2)
grid.addWidget(self.verify_password_input, 1, 3)
self.pw_match_label = QLabel("")
# 초기에는 두 비밀번호 필드가 비어있으므로 아무 텍스트도 표시하지 않음.
self.pw_match_label.setStyleSheet("font-size: 12px;")
grid.setRowMinimumHeight(1, 20)
grid.addWidget(self.pw_match_label,2,0,1,4)
# Row 1: 이름, 비밀번호 확인
name_label = QLabel("이름 (필수):")
self.name_input = QLineEdit()
self.name_input.setPlaceholderText("이름 입력")
grid.addWidget(name_label, 3, 0)
grid.addWidget(self.name_input, 3, 1)
# Row 2: 닉네임, 비밀번호 일치 여부 메시지 (span 2 columns)
nickname_label = QLabel("닉네임 (선택):")
self.nickname_input = QLineEdit()
self.nickname_input.setPlaceholderText("닉네임 입력 (선택)")
grid.addWidget(nickname_label, 3, 2)
grid.addWidget(self.nickname_input, 3, 3)
self.layout.addWidget(input_widget)
# 회원가입 버튼 (초기에는 비활성화)
self.signup_button = QPushButton("회원가입")
self.signup_button.setEnabled(False)
self.signup_button.setStyleSheet("""
QPushButton {
background-color: #1877f2;
color: white;
border-radius: 4px;
padding: 8px;
font-size: 14px;
}
QPushButton:disabled {
background-color: #cccccc;
color: #888888;
}
QPushButton:hover:!disabled {
background-color: #166fe5;
}
""")
self.signup_button.clicked.connect(self.handle_signup)
self.layout.addWidget(self.signup_button)
self.setLayout(self.layout)
# 입력 필드의 변경 신호 연결 (비밀번호 일치 및 필수 필드 확인)
self.password_input.textChanged.connect(self.validate_inputs)
self.verify_password_input.textChanged.connect(self.validate_inputs)
self.email_input.textChanged.connect(self.validate_inputs)
self.name_input.textChanged.connect(self.validate_inputs)
# 체크박스 상태 변화 연결 (toggled 시그널을 사용하여 boolean 값을 받음)
self.privacy_checkbox.toggled.connect(self.on_privacy_toggled)
self.program_license_checkbox.toggled.connect(self.check_tabs_status)
def is_password_valid(self, password: str) -> bool:
"""
비밀번호가 다음 조건을 충족하는지 검사합니다:
- 최소 8자 이상
- 최소 하나의 대문자 포함
- 최소 하나의 소문자 포함
- 최소 하나의 특수문자 포함 (예: !@#$%^&*(),.?":{}|<>)
"""
import re
if len(password) < 8:
return False
if not re.search(r"[A-Z]", password):
return False
if not re.search(r"[a-z]", password):
return False
if not re.search(r"[!@#$%^&*(),.?\":{}|<>]", password):
return False
return True
def on_privacy_toggled(self, checked: bool):
if checked:
# 개인정보 동의가 체크되면 라이센스 탭 활성화 및 자동 전환
self.tab_widget.setTabEnabled(1, True)
self.tab_widget.setCurrentWidget(self.license_tab)
else:
self.tab_widget.setTabEnabled(1, False)
self.tab_widget.setCurrentWidget(self.privacy_tab)
self.check_tabs_status()
def check_tabs_status(self):
"""
개인정보와 프로그램 라이센스 동의 체크박스가 모두 체크되고,
필수 필드가 모두 채워지며, 비밀번호가 일치할 경우 회원가입 버튼이 활성화됩니다.
"""
if (self.privacy_checkbox.isChecked() and
self.program_license_checkbox.isChecked() and
self.are_required_fields_filled() and
self.is_password_match()):
self.signup_button.setEnabled(True)
else:
self.signup_button.setEnabled(False)
def are_required_fields_filled(self) -> bool:
return (self.email_input.text().strip() != "" and
self.password_input.text().strip() != "" and
self.verify_password_input.text().strip() != "" and
self.name_input.text().strip() != "")
def is_password_match(self) -> bool:
return self.password_input.text() == self.verify_password_input.text()
def validate_inputs(self):
"""
비밀번호 일치 여부와 필수 입력 필드를 확인하여,
두 비밀번호 필드가 모두 비어있으면 아무것도 표시하지 않고,
값이 있을 경우 일치하면 긍정 메시지, 불일치하면 부정 메시지를 표시합니다.
그리고 check_tabs_status()를 호출합니다.
"""
pwd = self.password_input.text()
verify_pwd = self.verify_password_input.text()
if pwd == "" and verify_pwd == "":
self.pw_match_label.setText("")
# 비밀번호 조건 검증
elif not self.is_password_valid(pwd):
self.pw_match_label.setText("비밀번호는 최소 8자, 대문자, 소문자, 특수문자를 포함해야 합니다.")
self.pw_match_label.setStyleSheet("font-size: 12px; color: red;")
elif pwd != verify_pwd:
self.pw_match_label.setText("비밀번호가 일치하지 않습니다.")
self.pw_match_label.setStyleSheet("font-size: 12px; color: red;")
else:
self.pw_match_label.setText("비밀번호가 조건에 맞고 일치합니다.")
self.pw_match_label.setStyleSheet("font-size: 12px; color: green;")
self.check_tabs_status()
def load_announcements_content(self):
"""서버에서 공지사항을 불러와 CollapsibleAnnouncement 위젯으로 추가합니다."""
try:
announcements = self.supabase_manager.get_announcements()
if announcements:
announcements = sorted(announcements, key=lambda x: x["position"])
for ann in announcements:
widget = CollapsibleAnnouncement(ann["position"], ann["title"], ann["content"])
self.ann_layout.addWidget(widget)
else:
self.ann_layout.addWidget(QLabel("현재 공지사항이 없습니다."))
except Exception as e:
QMessageBox.warning(self, "오류", f"공지사항 로드 중 오류 발생: {e}")
def open_mail_provider(self, email: str):
"""
입력한 이메일의 도메인에 맞춰 기본 웹 브라우저에서 메일 로그인 페이지를 엽니다.
한국 사용자들이 주로 사용하는 도메인을 최대한 추가했습니다.
만약 매칭되는 도메인이 없다면 기본 페이지를 열고, 사용자가 직접 해당 메일에 접속하라는 안내를 합니다.
"""
domain = email.split('@')[-1].lower()
url = None
if "gmail" in domain:
url = "https://mail.google.com"
elif "naver" in domain:
url = "https://mail.naver.com"
elif "daum" in domain or "hanmail" in domain:
url = "https://mail.daum.net"
elif "nate" in domain:
url = "https://mail.nate.com"
elif "outlook" in domain or "hotmail" in domain or "live" in domain:
url = "https://outlook.live.com/owa/"
elif "yahoo" in domain:
url = "https://mail.yahoo.com"
# 추가 도메인이 필요하다면 아래에 조건을 추가하세요.
# 예: if "empas" in domain: url = "..."
if url:
webbrowser.open(url)
else:
# 매칭되는 이메일 공급자가 없을 경우,
# 사용자에게 직접 해당 메일 웹사이트에 접속하라는 안내 메시지를 출력하고 기본 페이지를 엽니다.
QMessageBox.information(None, "메일 로그인 안내",
f"입력하신 이메일({email})에 해당하는 메일 공급자를 찾을 수 없습니다.\n"
"직접 해당 메일 서비스에 접속하여 로그인해 주세요.")
webbrowser.open("https://www.google.com") # 또는 다른 기본 페이지
def handle_signup(self):
if not (self.privacy_checkbox.isChecked() and self.program_license_checkbox.isChecked()):
QMessageBox.warning(self, "동의 필요", "회원가입을 진행하려면 개인정보 및 라이센스 동의가 필요합니다.")
return
if not self.are_required_fields_filled():
QMessageBox.warning(self, "입력 오류", "모든 필수 필드를 입력해 주세요.")
return
if not self.is_password_match():
QMessageBox.warning(self, "비밀번호 오류", "비밀번호가 일치하지 않습니다.")
return
email = self.email_input.text().strip()
password = self.password_input.text().strip()
username = self.name_input.text().strip()
nickname = self.nickname_input.text().strip()
now_iso = datetime.now(timezone.utc).isoformat()
metadata = {
"username": username,
"nickname": nickname,
"privacy_consent": True,
"privacy_consent_date": now_iso,
"license_consent": True,
"license_consent_date": now_iso
}
user = self.supabase_manager.register(email, password, metadata=metadata)
# 반환값이 error를 포함한 dict인 경우
if isinstance(user, dict) and user.get("error"):
QMessageBox.warning(self, "회원가입 실패", user.get("error"))
return
if user:
# 회원가입 성공 후 EmailVerificationDialog 실행
verification_dialog = EmailVerificationDialog(self.logger, self.supabase_manager, user.id, email, metadata, self)
result = verification_dialog.exec() # 모달 창 실행
if result == QDialog.Accepted:
# 이메일 인증이 완료되었으므로, auth.users의 email_confirmed_at에 값이 채워졌다고 가정
# 이 시점에서 public.users 테이블에 추가 메타데이터를 동기화합니다.
sync_success = self.supabase_manager.sync_public_user_profile(user.id, metadata)
if sync_success:
QMessageBox.information(self, "회원가입 성공",
"회원가입에 성공했습니다.\n이메일 인증이 완료되었고, 프로필 동기화가 완료되었습니다.")
self.accept()
else:
QMessageBox.warning(self, "동기화 실패", "회원 프로필 동기화에 실패했습니다. 관리자에게 문의하세요.")
else:
QMessageBox.warning(self, "이메일 인증 실패", "이메일 인증이 완료되지 않아 회원가입이 취소되었습니다.")
else:
QMessageBox.warning(self, "회원가입 실패", "회원가입에 실패했습니다. 입력 정보를 다시 확인해주세요.")
import webbrowser
from PySide6.QtWidgets import QDialog, QVBoxLayout, QLabel, QProgressBar, QPushButton, QMessageBox
from PySide6.QtCore import QTimer, QTime
from datetime import datetime, timezone
import logging
class EmailVerificationDialog(QDialog):
"""
EmailVerificationDialog는 회원가입 후 이메일 인증이 완료되었는지를 주기적으로 확인합니다.
auth.users 테이블의 email_confirmed_at 필드가 채워지면,
추가 메타데이터를 public.users에 동기화(sync_public_user_profile)를 시도하고,
동기화가 성공하면 QDialog.Accepted를 반환하여 이메일 인증 완료로 처리합니다.
"""
def __init__(self, logger, supabase_manager, user_id: str, email: str, metadata: dict, parent=None):
super().__init__(parent)
self.logger = logger
self.supabase_manager = supabase_manager
self.user_id = user_id
self.email = email
self.metadata = metadata
self.setWindowTitle("이메일 인증 대기")
self.setFixedSize(400, 200)
self.setup_ui()
self.start_email_verification()
def setup_ui(self) -> None:
self.layout = QVBoxLayout(self)
self.verification_status_label = QLabel("이메일 인증 대기 중...")
self.layout.addWidget(self.verification_status_label)
self.progress_bar = QProgressBar()
self.progress_bar.setTextVisible(True)
self.layout.addWidget(self.progress_bar)
self.instructions_label = QLabel(
"인증 메일이 전송되었습니다. 스팸함도 확인해주세요.\n"
"메일이 보이지 않으면 '인증 메일 재전송' 버튼을 클릭하세요."
)
self.layout.addWidget(self.instructions_label)
self.resend_button = QPushButton("인증 메일 재전송")
self.resend_button.clicked.connect(self.handle_resend_email)
self.layout.addWidget(self.resend_button)
def start_email_verification(self) -> None:
self.verification_timeout = 300 # 5분 (300초)
self.verification_start_time = QTime.currentTime()
self.progress_bar.setMaximum(self.verification_timeout)
self.progress_bar.setValue(0)
self.open_mail_provider(self.email)
self.countdown_timer = QTimer(self)
self.countdown_timer.setInterval(1000) # 1초 간격
self.countdown_timer.timeout.connect(self.update_countdown)
self.countdown_timer.start()
self.verification_timer = QTimer(self)
self.verification_timer.setInterval(3000) # 3초 간격
self.verification_timer.timeout.connect(self.poll_verification)
self.verification_timer.start()
def update_countdown(self) -> None:
elapsed = self.verification_start_time.secsTo(QTime.currentTime())
remaining = self.verification_timeout - elapsed
if remaining >= 0:
self.verification_status_label.setText(f"남은 시간: {remaining}")
self.progress_bar.setValue(elapsed)
else:
self.verification_status_label.setText("인증 시간이 만료되었습니다.")
self.verification_timer.stop()
self.countdown_timer.stop()
QMessageBox.warning(self, "인증 실패", "제한 시간 내에 이메일 인증이 완료되지 않아 회원정보가 삭제되었습니다.\n다시 가입해 주세요.")
self.supabase_manager.delete_user(self.user_id)
self.reject()
def poll_verification(self) -> None:
elapsed = self.verification_start_time.secsTo(QTime.currentTime())
if elapsed >= self.verification_timeout:
self.verification_timer.stop()
self.countdown_timer.stop()
QMessageBox.warning(self, "인증 실패", "제한 시간 내에 이메일 인증이 완료되지 않아 회원정보가 삭제되었습니다.\n다시 가입해 주세요.")
self.supabase_manager.delete_user(self.user_id)
self.reject()
return
# auth.users 테이블에서 직접 이메일 인증 상태를 확인합니다.
# 이제 public.users 테이블에서 email_confirmed_at을 조회합니다.
auth_info = self.supabase_manager.get_auth_user_info(self.user_id)
self.logger.log(f"auth_info : {auth_info}", level=logging.DEBUG)
if auth_info and auth_info.get("email_confirmed_at"):
self.verification_timer.stop()
self.countdown_timer.stop()
self.verification_status_label.setText("이메일 인증이 완료되었습니다.")
# 인증 완료되었으므로 public.users 테이블에 추가 메타데이터 동기화 시도
sync_result = self.supabase_manager.sync_public_user_profile(self.user_id, self.metadata)
if isinstance(sync_result, int) and sync_result in (200, 204):
# 기본 QMessageBox.information() 대신 인스턴스 생성하여 항상 위로 표시하고 포커스 설정
msg_box = QMessageBox(self)
msg_box.setWindowTitle("인증 완료")
msg_box.setText("이메일 인증 및 프로필 동기화가 완료되었습니다.")
msg_box.setIcon(QMessageBox.Information)
msg_box.setWindowModality(Qt.ApplicationModal)
msg_box.setWindowFlag(Qt.WindowStaysOnTopHint, True)
msg_box.show()
msg_box.raise_() # 창을 최상위로 올림
msg_box.activateWindow() # 포커스 활성화
msg_box.exec() # 모달 다이얼로그 실행
self.accept()
else:
error_msg = sync_result.get("error") if isinstance(sync_result, dict) else "알 수 없는 오류"
QMessageBox.warning(self, "동기화 실패", f"회원 프로필 동기화에 실패했습니다: {error_msg}")
self.reject()
else:
# 인증이 아직 완료되지 않은 경우 프로그레스바 업데이트
value = self.progress_bar.value()
value = (value + 10) % self.verification_timeout
self.progress_bar.setValue(value)
def handle_resend_email(self) -> None:
success = self.supabase_manager.resend_verification_email(self.email)
if success:
QMessageBox.information(self, "재전송 성공", "인증 메일이 재전송되었습니다.\n스팸함도 확인해주세요.")
else:
QMessageBox.warning(self, "재전송 실패", "인증 메일 재전송 중 오류가 발생했습니다.\n관리자에게 문의하세요.")
def open_mail_provider(self, email: str) -> None:
"""
입력한 이메일 도메인에 따라 기본 웹 브라우저에서 메일 로그인 페이지를 엽니다.
매칭되는 도메인이 없으면 기본 페이지를 열고, 사용자가 직접 로그인하도록 안내합니다.
"""
domain = email.split('@')[-1].lower()
url = None
if "gmail" in domain:
url = "https://mail.google.com"
elif "naver" in domain:
url = "https://mail.naver.com"
elif "daum" in domain or "hanmail" in domain:
url = "https://mail.daum.net"
elif "nate" in domain:
url = "https://mail.nate.com"
elif "outlook" in domain or "hotmail" in domain or "live" in domain:
url = "https://outlook.live.com/owa/"
elif "yahoo" in domain:
url = "https://mail.yahoo.com"
# 필요시 추가 도메인 조건
if url:
webbrowser.open(url)
else:
QMessageBox.information(None, "메일 로그인 안내",
f"입력하신 이메일({email})에 해당하는 메일 공급자를 찾을 수 없습니다.\n"
"직접 해당 메일 서비스에 접속하여 로그인해 주세요.")
webbrowser.open("https://www.google.com")