- 회원가입 이메일 인증 페이지 추가 및 이메일 인증 흐름 구현
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled

- 휴대폰 번호 중복 검증 로직 추가 및 프로퍼티 기반 설정 활성화
- 알림 수신 동의 항목 및 뷰 로직 추가
This commit is contained in:
Rinjae
2026-06-22 19:20:03 +09:00
parent 21c500610c
commit 198f6fc82a
11 changed files with 455 additions and 11 deletions
@@ -23,6 +23,9 @@ public class UserSessionService {
private static final String PROPERTY_NAME = "session.timeout.minutes";
private static final String DEFAULT_TIMEOUT_MINUTES = "15";
/** 논리적 만료(lastAccessTime + 타임아웃) 이후 물리적 삭제까지 두는 안전 마진(분) */
private static final long CLEANUP_MARGIN_MINUTES = 1;
private final UserSessionRepository userSessionRepository;
private final PortalPropertyService portalPropertyService;
@@ -134,13 +137,15 @@ public class UserSessionService {
}
/**
* 만료 세션 정리 (5분 주기). 타임아웃의 2배 이상 지난 세션 삭제 (안전 마진).
* 만료 세션 정리 (1분 주기).
* 논리적 만료(lastAccessTime + 타임아웃)가 지난 세션을 소량 마진({@value #CLEANUP_MARGIN_MINUTES}분) 후 삭제한다.
* heartbeat/활동이 멈춰 더 이상 갱신되지 않는 세션(닫힌 탭·크래시·네트워크 단절 등)을 신속히 제거한다.
*/
@Scheduled(fixedRate = 300000)
@Scheduled(fixedRate = 60000)
@Transactional
public void cleanupExpiredSessions() {
int timeoutMinutes = getSessionTimeoutMinutes();
LocalDateTime cutoff = LocalDateTime.now().minusMinutes((long) timeoutMinutes * 2);
LocalDateTime cutoff = LocalDateTime.now().minusMinutes(timeoutMinutes + CLEANUP_MARGIN_MINUTES);
int deleted = userSessionRepository.deleteExpiredSessions(cutoff);
if (deleted > 0) {
log.info("만료 세션 정리 - 삭제 수: {}", deleted);
@@ -2,6 +2,7 @@ package com.eactive.apim.portal.apps.user.controller;
import com.eactive.apim.portal.apps.user.dto.ValidationResponse;
import com.eactive.apim.portal.apps.user.facade.AuthFacade;
import com.eactive.apim.portal.apps.user.service.PortalUserService;
import com.eactive.apim.portal.apps.user.validator.EmailValidator;
import com.eactive.apim.portal.common.validator.CellPhoneValidator;
import org.springframework.web.bind.annotation.PostMapping;
@@ -22,18 +23,22 @@ public class AuthController {
private final AuthFacade authFacade;
private final CellPhoneValidator cellPhoneValidator;
private final EmailValidator emailValidator;
private final PortalUserService portalUserService;
public AuthController(AuthFacade authFacade, CellPhoneValidator cellPhoneValidator, EmailValidator emailValidator) {
public AuthController(AuthFacade authFacade, CellPhoneValidator cellPhoneValidator, EmailValidator emailValidator,
PortalUserService portalUserService) {
this.authFacade = authFacade;
this.cellPhoneValidator = cellPhoneValidator;
this.emailValidator = emailValidator;
this.portalUserService = portalUserService;
}
@PostMapping("/request_auth_number")
public ValidationResponse requestAuthNumber(
@RequestParam(required = false) String mobileNumber,
@RequestParam(required = false) String purpose,
HttpSession session) {
ValidationResponse response = new ValidationResponse();
@@ -44,6 +49,16 @@ public class AuthController {
return response;
}
// 회원가입 흐름에서는 인증번호 발송 전에 휴대폰 번호 중복을 미리 검증한다.
// (중복 번호를 인증까지 마친 뒤 가입 단계에서야 거절되는 것을 방지)
// mobileNumber 는 저장 포맷과 동일한 하이픈 포함 형태이므로 sanitize 전 값으로 조회한다.
if ("signup".equals(purpose) && portalUserService.isMobileDuplicateCheckEnabled()
&& portalUserService.existsByMobileNumber(mobileNumber)) {
response.setValid(false);
response.setMessage("이미 가입된 휴대폰 번호입니다.");
return response;
}
String sanitizedMobile = HtmlUtils.htmlEscape(mobileNumber.replace("-", ""));
// 이전 세션 데이터 정리
@@ -30,6 +30,7 @@ public class OrgRegisterController {
model.addAttribute("portalOrg", new PortalOrgRegistrationDTO());
model.addAttribute("termsOfUse", agreementsFacade.getAgreement("TERMS_OF_USE"));
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT_ORG"));
model.addAttribute("notificationConsent", agreementsFacade.getAgreement("NOTIFICATION_CONSENT"));
return MAIN_ORG_REGISTER;
}
@@ -92,5 +93,6 @@ public class OrgRegisterController {
model.addAttribute("error", errorMessage);
model.addAttribute("termsOfUse", agreementsFacade.getAgreement("TERMS_OF_USE"));
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT_ORG"));
model.addAttribute("notificationConsent", agreementsFacade.getAgreement("NOTIFICATION_CONSENT"));
}
}
@@ -4,6 +4,7 @@ import com.eactive.apim.portal.apps.agreements.service.AgreementsFacade;
import com.eactive.apim.portal.apps.user.dto.PortalUserRegistrationDTO;
import com.eactive.apim.portal.apps.user.dto.UserAgreementDTO;
import com.eactive.apim.portal.apps.user.dto.ValidationResponse;
import com.eactive.apim.portal.apps.user.facade.AuthFacade;
import com.eactive.apim.portal.apps.user.facade.UserRegisterFacade;
import com.eactive.apim.portal.apps.user.repository.PortalOrgRepository;
import com.eactive.apim.portal.apps.user.service.PortalUserService;
@@ -16,11 +17,15 @@ import com.eactive.apim.portal.invitation.entity.UserInvitationEnums;
import com.eactive.apim.portal.invitation.repository.UserInvitationRepository;
import com.eactive.apim.portal.portalorg.entity.PortalOrg;
import com.eactive.apim.portal.portaluser.entity.PortalUser;
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.util.Map;
import java.util.Optional;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RequestBody;
import javax.crypto.BadPaddingException;
import javax.crypto.IllegalBlockSizeException;
import javax.crypto.NoSuchPaddingException;
@@ -50,6 +55,7 @@ public class UserRegisterController {
private final PortalUserService portalUserService;
private final UserRegisterFacade userRegisterFacade;
private final AuthFacade authFacade;
private final PortalUserRepository portalUserRepository;
private final PortalOrgRepository portalOrgRepository;
private final AgreementsFacade agreementsFacade;
@@ -103,6 +109,7 @@ public class UserRegisterController {
model.addAttribute("portalUser", new PortalUserRegistrationDTO());
model.addAttribute("termsOfUse", agreementsFacade.getAgreement("TERMS_OF_USE"));
model.addAttribute("privacyCollect", agreementsFacade.getAgreement(isInvited ? "PRIVACY_COLLECT_ORG" : "PRIVACY_COLLECT_IND"));
model.addAttribute("notificationConsent", agreementsFacade.getAgreement("NOTIFICATION_CONSENT"));
return MAIN_USER_REGISTER;
@@ -138,8 +145,21 @@ public class UserRegisterController {
}
// 성공 시 PRG 패턴 적용: 결과 페이지로 리다이렉트
session.removeAttribute("invitationToken");
// 개인 가입자 중 이메일 인증 대상(READY 상태)은 회원가입 직후 바로 이메일 인증 단계로 이동
if (invitationToken == null) {
Optional<PortalUser> registered = portalUserService.findByLoginId(portalUserRegistrationDTO.getLoginId());
if (registered.isPresent()
&& PortalUserEnums.UserStatus.READY.equals(registered.get().getUserStatus())) {
session.setAttribute("signupVerificationEmail", registered.get().getEmailAddr());
return "redirect:/signup/verification-email";
}
redirectAttributes.addFlashAttribute("message", "회원가입이 완료되었습니다.");
return "redirect:/signup/complete";
}
redirectAttributes.addFlashAttribute("message", "회원가입이 완료되었습니다.");
return invitationToken != null ? "redirect:/signup/complete/corporate" : "redirect:/signup/complete";
return "redirect:/signup/complete/corporate";
} catch (Exception e) {
setModelForError(session, model, agreement, portalUserRegistrationDTO,
"처리 중 오류가 발생했습니다: " + e.getMessage());
@@ -169,6 +189,57 @@ public class UserRegisterController {
return MAIN_EMAIL_COMPLETED;
}
/**
* 회원가입 직후 이메일 인증 페이지.
* 가입 단계에서 세션에 저장된 이메일이 있어야 진입 가능하다.
*/
@GetMapping("/signup/verification-email")
public String showSignupVerificationEmail(HttpSession session, Model model) {
String email = (String) session.getAttribute("signupVerificationEmail");
if (email == null) {
return "redirect:/login";
}
model.addAttribute("email", email);
return "apps/register/signupVerificationEmail";
}
/**
* 회원가입 이메일 인증코드 발송. 임의 이메일 타깃 방지를 위해 세션에 저장된 가입 이메일만 사용한다.
*/
@PostMapping("/signup/send-verification-code")
public ResponseEntity<ValidationResponse> sendSignupVerificationCode(HttpSession session) {
String email = (String) session.getAttribute("signupVerificationEmail");
if (email == null) {
return ResponseEntity.badRequest()
.body(new ValidationResponse(false, "유효하지 않은 요청입니다. 회원가입을 다시 진행해주세요."));
}
ValidationResponse response = authFacade.requestAuth(email, "EMAIL");
return ResponseEntity.ok(response);
}
/**
* 회원가입 이메일 인증코드 검증. 성공 시 사용자 상태를 READY → ACTIVE 로 활성화한다.
*/
@PostMapping("/signup/verify-email-code")
public ResponseEntity<ValidationResponse> verifySignupEmailCode(@RequestBody Map<String, String> request,
HttpSession session) {
String email = (String) session.getAttribute("signupVerificationEmail");
if (email == null) {
return ResponseEntity.badRequest()
.body(new ValidationResponse(false, "유효하지 않은 요청입니다. 회원가입을 다시 진행해주세요."));
}
String code = request.get("code");
ValidationResponse response = authFacade.verifyAuthNumber(email, code);
if (response.isValid()) {
PortalUser user = portalUserService.findByEmailAddr(email);
portalUserService.activateUser(user.getId());
session.removeAttribute("signupVerificationEmail");
}
return ResponseEntity.ok(response);
}
@GetMapping("/signup/decision")
public String showDecisionPage(@RequestParam(name = "invitation", required = false) String invitationToken,
HttpSession session,
@@ -326,6 +397,7 @@ public class UserRegisterController {
model.addAttribute("termsOfUse", agreementsFacade.getAgreement("TERMS_OF_USE"));
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT_IND"));
model.addAttribute("notificationConsent", agreementsFacade.getAgreement("NOTIFICATION_CONSENT"));
model.addAttribute("msgType", "sms");
}
@@ -137,12 +137,11 @@ public class UserRegisterFacadeImpl implements UserRegisterFacade {
return new ValidationResponse(false, "이미 사용 중인 이메일입니다.");
}
// 25.10.01 - 휴대폰 번호 중복이여도 가입 가능
// PortalUser existingUser = portalUserRepository.findByUserNameAndMobileNumber(registrationDTO.getUserName(), registrationDTO.getMobileNumber());
//
// if(existingUser != null) {
// return new ValidationResponse(false, "가입된 계정이 이미 존재합니다.");
// }
// 휴대폰 번호 중복 검증 (Portal/user.mobile.duplicate.allow 프로퍼티에 따라 차단)
if (portalUserService.isMobileDuplicateCheckEnabled()
&& portalUserService.existsByMobileNumber(registrationDTO.getMobileNumber())) {
return new ValidationResponse(false, "이미 가입된 휴대폰 번호입니다.");
}
// 3. 사용자 등록 ("personal" 등록 유형으로 가정)
PortalUser newUser = portalUserService.registerActiveUser(registrationDTO, "personal");
@@ -173,6 +172,12 @@ public class UserRegisterFacadeImpl implements UserRegisterFacade {
return new ValidationResponse(false,"입력값을 확인해 주세요.");
}
// 휴대폰 번호 중복 검증 (Portal/user.mobile.duplicate.allow 프로퍼티에 따라 차단)
if (portalUserService.isMobileDuplicateCheckEnabled()
&& portalUserService.existsByMobileNumber(registrationDTO.getMobileNumber())) {
return new ValidationResponse(false, "이미 가입된 휴대폰 번호입니다.");
}
PortalUser existingUser = portalUserRepository.findByUserNameAndMobileNumber(registrationDTO.getUserName(), registrationDTO.getMobileNumber());
if(existingUser != null) {
@@ -24,6 +24,12 @@ import org.springframework.util.StringUtils;
@Service
@Transactional
public class PortalUserService {
/** PortalProperty 그룹명 */
private static final String PORTAL_PROPERTY_GROUP = "Portal";
/** 휴대폰 번호 중복 허용 프로퍼티 키 (true:허용, false:허용안함, 기본 false) */
private static final String PROP_MOBILE_DUPLICATE_ALLOW = "user.mobile.duplicate.allow";
private final PortalUserRepository portalUserRepository;
private final PortalOrgRepository portalOrgRepository;
private final PasswordEncoder passwordEncoder;
@@ -68,6 +74,33 @@ public class PortalUserService {
return portalUserRepository.existsByLoginId(normalizedLoginId);
}
/**
* 휴대폰 번호 중복 여부 확인.
* mobileNumber 는 저장 포맷(하이픈 포함, 예: 010-1234-5678)과 동일한 형태로 전달해야 한다.
* (암호화 컬럼이므로 결정적 암호화 기준 평등 조회로 매칭된다)
*/
public boolean existsByMobileNumber(String mobileNumber) {
if (mobileNumber == null || mobileNumber.trim().isEmpty()) {
return false;
}
return portalUserRepository.existsByMobileNumber(mobileNumber.trim());
}
/**
* 회원가입 시 휴대폰 번호 중복 차단 활성 여부.
* 공유 프로퍼티(Portal/user.mobile.duplicate.allow)를 true/false 로 해석한다.
* - allow=true(또는 레거시 Y): 중복 허용 → 차단 비활성
* - allow=false(또는 레거시 N) / 미설정: 중복 허용안함 → 차단 활성 (기본값 false)
*/
@Transactional(readOnly = true)
public boolean isMobileDuplicateCheckEnabled() {
String allow = portalPropertyService
.getPortalPropertiesAsMap(PORTAL_PROPERTY_GROUP)
.getOrDefault(PROP_MOBILE_DUPLICATE_ALLOW, "false");
boolean allowed = "true".equalsIgnoreCase(allow) || "Y".equalsIgnoreCase(allow);
return !allowed;
}
public boolean checkOrgHasOtherUsers(PortalOrg portalOrg) {
return portalUserRepository.countByPortalOrg(portalOrg) > 1;
}
@@ -317,6 +317,7 @@
type: 'POST',
data: {
mobileNumber: mobileNumber,
purpose: 'signup',
_csrf: $('input[name="_csrf"]').val()
},
success: function(response) {
@@ -237,6 +237,14 @@
form.appendChild(input);
}
if (document.getElementById('notificationConsent')) {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'notificationConsent';
input.value = document.getElementById('notificationConsent').checked;
form.appendChild(input);
}
// 시스템 필드 추가
const csrfToken = document.querySelector('input[name="_csrf"]');
if (csrfToken) {
@@ -338,9 +346,11 @@
return false;
}
const notificationConsentEl = document.getElementById('notificationConsent');
const validations = {
termsOfUse: $('#termsOfUse').prop('checked'),
privacyPolicy: $('#privacyCollect').prop('checked'),
notificationConsent: !notificationConsentEl || notificationConsentEl.checked,
compRegNo: $('#compRegNo').val().trim() !== '',
corpRegNo: $('#corpRegNo').val().trim() !== '',
orgName: $('#orgName').val().trim() !== '',
@@ -0,0 +1,277 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout/djbank_title_layout}">
<body>
<section layout:fragment="title">
<div class="page-title-banner">
<img th:src="@{/img/img_title_bg.png}" class="title-image">
<h1>회원가입</h1>
</div>
</section>
<section layout:fragment="contentFragment">
<div class="org-register-container">
<div class="common-title-bar">
<h2 class="common-title">이메일 인증</h2>
</div>
<div class="register-form-container">
<p class="form-hint" style="margin-bottom: 16px;">
회원가입을 완료하려면 가입하신 이메일 주소로 인증을 진행해 주세요.
</p>
<form id="signupVerificationEmailForm" role="form" name="signupVerificationEmailForm" method="post">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/>
<div class="register-form">
<!-- 이메일 주소 + 인증코드 받기 버튼 (한 줄) -->
<div class="form-row" style="margin-bottom: 0">
<div class="form-label-wrapper label-offset">
<span class="form-label-text">이메일 주소</span>
</div>
<div class="form-field-wrapper input-with-button">
<input type="email" id="email" name="email" class="form-input input-readonly"
th:value="${email}" readonly disabled="disabled">
<button type="button" class="btn-input-action btn-auth btn_send_code">인증코드 받기</button>
</div>
</div>
<div class="form-row" style="margin-bottom: 0">
<div class="form-label-wrapper label-offset">
<span class="form-label-text">&nbsp;</span>
</div>
<div class="form-field-wrapper">
<p class="form-hint" style="margin-top: 0;">위 이메일 주소로 인증코드가 발송됩니다.</p>
</div>
</div>
<!-- 인증번호 입력 -->
<div class="form-row" id="authCodeContainer" style="display: none;">
<div class="form-label-wrapper label-offset">
<span class="form-label-text">인증코드 입력</span> <span class="required-badge">필수</span>
</div>
<div class="form-field-wrapper input-with-button">
<div class="auth-input-group">
<input type="text" id="authCode" name="authCode" maxlength="6" pattern="[0-9]*"
inputmode="numeric" class="form-input"
placeholder="이메일로 받은 인증코드 6자리">
<span class="auth-timer" id="certify_time">05:00</span>
</div>
<button type="button" class="btn-input-action btn-auth btn_verify_code">인증코드 확인</button>
</div>
</div>
</div>
</form>
</div>
<!-- Action Buttons -->
<div class="form-actions">
<button type="button" class="btn-submit btn-secondary btn_cancel">나중에 인증</button>
</div>
</div>
</section>
</body>
<th:block layout:fragment="contentScript">
<script th:inline="javascript">
$(document).ready(function() {
let timerInterval;
let resendTimerInterval;
let isVerified = false;
// CSRF 토큰 설정
const csrfHeaderName = /*[[${_csrf.headerName}]]*/ 'X-XSRF-TOKEN';
const csrfToken = /*[[${_csrf.token}]]*/ '';
// 서버 설정값
const resendLimitSeconds = /*[[${@environment.getProperty('portal.auth.resend_limit_seconds', '60')}]]*/ 60;
// "나중에 인증" 클릭 시: 가입은 완료된 상태이므로 로그인 페이지로 이동 (로그인 후 인증 유도)
$('.btn_cancel').on('click', function() {
customPopups.showConfirm(
'이메일 인증을 나중에 하시겠습니까? 로그인 후에도 인증을 완료할 수 있습니다.',
function() {
location.href = /*[[@{/login}]]*/ '/login';
}
);
});
// 타이머 시작 함수 (인증 코드 유효 시간)
function startTimer(duration) {
let timer = duration;
clearInterval(timerInterval);
timerInterval = setInterval(function () {
let minutes = parseInt(timer / 60, 10);
let seconds = parseInt(timer % 60, 10);
minutes = minutes < 10 ? "0" + minutes : minutes;
seconds = seconds < 10 ? "0" + seconds : seconds;
$('#certify_time').text(minutes + ":" + seconds).css('color', '#666');
if (--timer < 0) {
clearInterval(timerInterval);
$('#certify_time').text("시간 초과").css('color', '#FF6B6B');
customPopups.showAlert("인증 시간이 만료되었습니다. 인증코드를 다시 받아주세요.");
$('.btn_send_code').prop('disabled', false).text('인증코드 재발송');
$('.btn_verify_code').prop('disabled', true);
$('#authCode').prop('readonly', true);
}
}, 1000);
}
// 재발송 타이머 시작 함수
function startResendTimer() {
let resendTimer = resendLimitSeconds;
const $button = $('.btn_send_code');
clearInterval(resendTimerInterval);
$button.prop('disabled', true);
resendTimerInterval = setInterval(function () {
if (resendTimer > 0) {
$button.text('재발송 가능 (' + resendTimer + '초)');
resendTimer--;
} else {
clearInterval(resendTimerInterval);
$button.prop('disabled', false).text('인증코드 재발송');
}
}, 1000);
}
// 테스트 환경 안내 메시지 표시
function displayTestAuthNotice(authNumber) {
const noticeHtml = `
<div class="test-env-notice" style="margin-top: 12px; padding: 12px 16px; background-color: #FFF4E6; border: 1px solid #FFB84D; border-radius: 4px;">
<div class="test-env-notice__content" style="display: flex; align-items: flex-start; gap: 8px;">
<svg class="test-env-notice__icon" style="flex-shrink: 0; margin-top: 2px; width: 20px; height: 20px;" viewBox="0 0 20 20" fill="none">
<path d="M10 0C4.48 0 0 4.48 0 10C0 15.52 4.48 20 10 20C15.52 20 20 15.52 20 10C20 4.48 15.52 0 10 0ZM11 15H9V13H11V15ZM11 11H9V5H11V11Z" fill="#FF9800"/>
</svg>
<div class="test-env-notice__text-container" style="flex: 1;">
<p style="margin: 0; font-size: 14px; color: #E65100; font-weight: 500;">테스트 환경 안내</p>
<p style="margin: 4px 0 0 0; font-size: 13px; color: #5D4037; line-height: 1.5;">
현재 테스트 환경입니다. 실제로 이메일이 발송되지 않으며, 인증번호는
<strong style="color: #E65100; font-family: monospace; font-size: 14px; font-weight: bold;">${authNumber}</strong>
입니다.
</p>
</div>
</div>
</div>
`;
// 기존 안내 메시지 제거
$('.test-env-notice').remove();
// authCodeContainer 바로 앞에 안내 메시지 삽입
const authCodeContainer = document.getElementById('authCodeContainer');
if (authCodeContainer) {
const noticeDiv = document.createElement('div');
noticeDiv.className = 'form-row';
noticeDiv.innerHTML = `<div class="form-label-wrapper label-offset"></div><div class="form-field-wrapper">${noticeHtml}</div>`;
authCodeContainer.parentNode.insertBefore(noticeDiv, authCodeContainer);
}
}
// 인증코드 받기 버튼 클릭
$('.btn_send_code').on('click', function(e) {
e.preventDefault();
const $button = $(this);
$button.prop('disabled', true).text('발송 중...');
$.ajax({
url: /*[[@{/signup/send-verification-code}]]*/ '/signup/send-verification-code',
type: 'POST',
contentType: 'application/json',
data: '{}',
headers: {
[csrfHeaderName]: csrfToken
},
success: function(response) {
if (!response.valid) {
customPopups.showAlert(response.message || '인증코드 발송에 실패했습니다.');
$button.prop('disabled', false).text('인증코드 받기');
return;
}
// 테스트 환경 인증번호 표시
if (response.authNumber) {
displayTestAuthNotice(response.authNumber);
}
customPopups.showAlert('인증코드가 이메일로 발송되었습니다.');
$('#authCodeContainer').slideDown(300);
$('#authCode').prop('readonly', false);
$('.btn_verify_code').prop('disabled', false);
startTimer(300); // 5분 타이머
startResendTimer(); // 재발송 타이머 시작
},
error: function() {
customPopups.showAlert('인증코드 발송에 실패했습니다.');
$button.prop('disabled', false).text('인증코드 받기');
}
});
});
// 인증코드 확인 버튼 클릭
$('.btn_verify_code').on('click', function(e) {
e.preventDefault();
const authCode = $('#authCode').val().trim();
if (!authCode) {
customPopups.showAlert('인증코드를 입력해주세요.');
return;
}
if (authCode.length !== 6) {
customPopups.showAlert('6자리 인증코드를 입력해주세요.');
return;
}
$.ajax({
url: /*[[@{/signup/verify-email-code}]]*/ '/signup/verify-email-code',
type: 'POST',
contentType: 'application/json',
data: JSON.stringify({ code: authCode }),
headers: {
[csrfHeaderName]: csrfToken
},
success: function(response) {
if (!response.valid) {
customPopups.showAlert(response.message || '인증코드가 일치하지 않습니다.');
return;
}
clearInterval(timerInterval);
clearInterval(resendTimerInterval);
isVerified = true;
// 인증 완료 UI 업데이트
$('#authCode').prop('disabled', true);
$('.btn_verify_code').prop('disabled', true);
$('.btn_send_code').prop('disabled', true);
$('#certify_time').text('인증 완료').css('color', '#6BCF7F');
customPopups.showAlert('이메일 인증이 완료되었습니다! 로그인 후 이용해 주세요.');
// 알림 확인 후 로그인 페이지로 이동
setTimeout(function() {
location.href = /*[[@{/login}]]*/ '/login';
}, 2000);
},
error: function() {
customPopups.showAlert('인증코드 확인에 실패했습니다.');
}
});
});
// 인증코드 입력 필드 숫자만 허용
$('#authCode').on('input', function() {
$(this).val($(this).val().replace(/[^0-9]/g, ''));
});
});
</script>
</th:block>
</html>
@@ -71,6 +71,28 @@
<div class="editor-content" th:utext="${privacyCollect?.contents ?: '개인정보수집동의서 내용을 불러올 수 없습니다.'}"></div>
</div>
</div>
<!-- Notification Consent (모델에 notificationConsent가 있을 때만 노출) -->
<th:block th:if="${notificationConsent != null}">
<div class="agreement-item-row">
<label class="agreement-checkbox-label">
<input type="checkbox" name="notificationConsent" id="notificationConsent" class="agreement-checkbox-input" required>
<span class="agreement-checkbox-custom"></span>
<span class="agreement-checkbox-text">
<span class="agreement-required">[필수]</span>
<span class="agreement-title">알림 수신 동의</span>
</span>
</label>
<button type="button" class="agreement-toggle-icon" data-target="notificationConsentContent">
<i class="fas fa-chevron-down"></i>
</button>
</div>
<div id="notificationConsentContent" class="agreement-content" style="display:none;">
<div class="agreement-scroll">
<div class="editor-content" th:utext="${notificationConsent?.contents ?: '알림 수신 동의서 내용을 불러올 수 없습니다.'}"></div>
</div>
</div>
</th:block>
</div>
</form>
</th:block>
@@ -105,6 +105,7 @@
const userNameElement = $('#userName');
const termsOfUseElement = $('#termsOfUse');
const privacyCollectElement = $('#privacyCollect');
const notificationConsentElement = $('#notificationConsent');
const mobileNumberElement = $('#mobileNumber');
const isPasswordValid = $('#isPasswordValid').val() === 'true';
const isPasswordMatch = $('#isPasswordMatch').val() === 'true';
@@ -115,6 +116,7 @@
userNameElement.val().trim() !== '' &&
termsOfUseElement.prop('checked') &&
privacyCollectElement.prop('checked') &&
(notificationConsentElement.length === 0 || notificationConsentElement.prop('checked')) &&
mobileNumberElement.val().trim() !== '' &&
isAuthVerified;
}