- 회원가입 이메일 인증 페이지 추가 및 이메일 인증 흐름 구현
- 휴대폰 번호 중복 검증 로직 추가 및 프로퍼티 기반 설정 활성화 - 알림 수신 동의 항목 및 뷰 로직 추가
This commit is contained in:
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
+73
-1
@@ -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"> </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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user