법인 회원 초대 대응

This commit is contained in:
Rinjae
2025-11-24 22:23:04 +09:00
parent a7d94c4d92
commit d7a32e1dcf
16 changed files with 2293 additions and 20 deletions
@@ -10,6 +10,9 @@ import com.eactive.apim.portal.apps.user.facade.OrgRegisterFacade;
import com.eactive.apim.portal.apps.user.facade.UserFacade;
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.common.util.SecurityUtil;
import com.eactive.apim.portal.invitation.entity.UserInvitation;
import com.eactive.apim.portal.invitation.entity.UserInvitationEnums.InvitationStatus;
import com.eactive.apim.portal.invitation.repository.UserInvitationRepository;
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.RoleCode;
import java.util.Arrays;
@@ -47,6 +50,7 @@ public class AccountController {
private final OrgRegisterFacade orgRegisterFacade;
private final AgreementsFacade agreementsFacade;
private final com.eactive.apim.portal.apps.user.facade.AuthFacade authFacade;
private final UserInvitationRepository userInvitationRepository;
@PostMapping("/confirm_password")
@@ -122,7 +126,8 @@ public class AccountController {
try {
// 현재 로그인된 사용자의 정보를 가져옴
PortalUserDTO user = userFacade.findById(SecurityUtil.getPortalAuthenticatedUser().getId());
PortalAuthenticatedUser currentUser = SecurityUtil.getPortalAuthenticatedUser();
PortalUserDTO user = userFacade.findById(currentUser.getId());
// 개별 필드들을 모델에 추가
mav.addObject("loginId", user.getLoginId());
@@ -132,6 +137,20 @@ public class AccountController {
// 기존 user 객체도 유지 (다른 곳에서 필요할 수 있으므로)
mav.addObject("user", user);
// ROLE_USER인 경우 초대 여부 확인
if (currentUser.getRoleCode() == RoleCode.ROLE_USER) {
java.util.Optional<UserInvitation> pendingInvitation =
userInvitationRepository.findFirstByInvitationEmailAndStatus(
user.getEmailAddr(), InvitationStatus.PENDING);
if (pendingInvitation.isPresent()) {
mav.addObject("hasPendingInvitation", true);
mav.addObject("invitationToken", pendingInvitation.get().getToken());
} else {
mav.addObject("hasPendingInvitation", false);
}
}
// IP 목록을 분리하여 모델에 추가 (법인 관리자인 경우만)
if (user.getRoleCode() == PortalUserEnums.RoleCode.ROLE_CORP_MANAGER
&& user.getPortalOrg() != null
@@ -143,7 +162,7 @@ public class AccountController {
}
// 사용자의 RoleCode에 따라 다른 페이지로 이동
switch (SecurityUtil.getPortalAuthenticatedUser().getRoleCode()) {
switch (currentUser.getRoleCode()) {
case ROLE_USER:
mav.setViewName("apps/mypage/updatePersonalUser");
break;
@@ -43,8 +43,20 @@ public class UserManRestController {
//5. register new user
@PostMapping("/invite")
public ResponseDTO sendInvitation(@RequestBody PortalUserDTO newUser) {
String email = newUser.getEmailAddr();
userManFacade.sendInvitation(SecurityUtil.getPortalAuthenticatedUser(), newUser.getEmailAddr());
// 이메일 형식 검증
if (email == null || email.trim().isEmpty()) {
return new ResponseDTO(400, "ERROR", "이메일 주소를 입력해주세요.");
}
// 이메일 정규식 검증
String emailPattern = "^[a-zA-Z0-9._%+\\-]+@[a-zA-Z0-9.\\-]+\\.[a-zA-Z]{2,}$";
if (!email.matches(emailPattern)) {
return new ResponseDTO(400, "ERROR", "올바른 이메일 형식을 입력해주세요.");
}
userManFacade.sendInvitation(SecurityUtil.getPortalAuthenticatedUser(), email);
return new ResponseDTO(200, "SUCCESS", "요청 메일이 발송되었습니다.");
}
@@ -56,4 +68,11 @@ public class UserManRestController {
return new ResponseDTO(200, "SUCCESS", "변경되었습니다. 확인 버튼을 누르시면 계정을 로그아웃 합니다.");
}
// 6. Resend invitation
@PostMapping("/resend-invitation")
public ResponseDTO resendInvitation(@RequestBody PortalUserDTO user) {
userManFacade.resendInvitation(SecurityUtil.getPortalAuthenticatedUser(), user.getId());
return new ResponseDTO(200, "SUCCESS", "초대가 재발송되었습니다.");
}
}
@@ -71,7 +71,8 @@ public class UserRegisterController {
String[] parts = null;
if (invitationToken != null) {
String decodedToken = new String(Base64.decode(invitationToken));
// 8글자 토큰 사용
String decodedToken = decodeInvitationToken(invitationToken);
session.setAttribute("invitationToken", decodedToken);
Optional<UserInvitation> invitation = userInvitationRepository.findByToken(decodedToken);
if (invitation.isPresent() && invitation.get().getStatus() == UserInvitationEnums.InvitationStatus.PENDING) {
@@ -153,7 +154,8 @@ public class UserRegisterController {
return "redirect:/";
}
String decodedToken = new String(Base64.decode(invitationToken));
// 8글자 토큰 사용
String decodedToken = decodeInvitationToken(invitationToken);
session.setAttribute("decisionToken", decodedToken);
Optional<UserInvitation> invitation = userInvitationRepository.findByToken(decodedToken);
@@ -294,4 +296,124 @@ public class UserRegisterController {
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT_IND"));
model.addAttribute("msgType", "sms");
}
/**
* 초대 토큰 반환
* @param invitationToken 원본 토큰 (8글자 토큰)
* @return 토큰
*/
private String decodeInvitationToken(String invitationToken) {
return invitationToken;
}
/**
* 초대 코드 입력 페이지 표시
*/
@GetMapping("/signup/invitation-code")
public String showInvitationCodeInput(HttpSession session, Model model) {
// 브루트포스 방지: 세션에서 시도 횟수 확인
Integer failedAttempts = (Integer) session.getAttribute("invitationCodeAttempts");
Long lockoutUntil = (Long) session.getAttribute("invitationCodeLockoutUntil");
if (lockoutUntil != null && lockoutUntil > System.currentTimeMillis()) {
// 잠금 상태: 남은 시간 계산
long remainingSeconds = (lockoutUntil - System.currentTimeMillis()) / 1000;
long remainingMinutes = remainingSeconds / 60;
long seconds = remainingSeconds % 60;
String lockoutTime = String.format("%d분 %d초", remainingMinutes, seconds);
model.addAttribute("lockoutTime", lockoutTime);
model.addAttribute("attemptsRemaining", 0);
} else {
// 잠금 해제: 시도 횟수 표시
if (failedAttempts == null) {
failedAttempts = 0;
}
int attemptsRemaining = 5 - failedAttempts;
model.addAttribute("attemptsRemaining", attemptsRemaining);
}
return "apps/register/invitationCodeInput";
}
/**
* 초대 코드 입력 처리
*/
@PostMapping("/signup/invitation-code")
public String processInvitationCode(
@RequestParam("invitationCode") String invitationCode,
HttpSession session,
RedirectAttributes redirectAttributes,
Model model) {
// 브루트포스 방지: 잠금 상태 확인
Long lockoutUntil = (Long) session.getAttribute("invitationCodeLockoutUntil");
if (lockoutUntil != null && lockoutUntil > System.currentTimeMillis()) {
long remainingSeconds = (lockoutUntil - System.currentTimeMillis()) / 1000;
long remainingMinutes = remainingSeconds / 60;
long seconds = remainingSeconds % 60;
String lockoutTime = String.format("%d분 %d초", remainingMinutes, seconds);
model.addAttribute("lockoutTime", lockoutTime);
model.addAttribute("error", "잠금 해제 시간을 기다려 주세요.");
return "apps/register/invitationCodeInput";
}
// 초대 코드 정규화 (대문자 변환 및 공백 제거)
String normalizedCode = invitationCode.toUpperCase().trim();
// 초대 코드 검증
Optional<UserInvitation> invitationOpt = userInvitationRepository.findByToken(normalizedCode);
if (!invitationOpt.isPresent() ||
invitationOpt.get().getStatus() != UserInvitationEnums.InvitationStatus.PENDING) {
// 실패 처리: 시도 횟수 증가
Integer failedAttempts = (Integer) session.getAttribute("invitationCodeAttempts");
if (failedAttempts == null) {
failedAttempts = 0;
}
failedAttempts++;
session.setAttribute("invitationCodeAttempts", failedAttempts);
if (failedAttempts >= 5) {
// 5회 실패: 15분 잠금
long lockoutTime = System.currentTimeMillis() + (15 * 60 * 1000); // 15분
session.setAttribute("invitationCodeLockoutUntil", lockoutTime);
session.setAttribute("invitationCodeAttempts", 0); // 시도 횟수 초기화
model.addAttribute("lockoutTime", "15분 0초");
model.addAttribute("error", "잘못된 초대 코드를 5회 입력하여 15분간 잠금되었습니다.");
} else {
// 실패 메시지와 남은 시도 횟수 표시
int attemptsRemaining = 5 - failedAttempts;
model.addAttribute("attemptsRemaining", attemptsRemaining);
model.addAttribute("error", "유효하지 않은 초대 코드입니다. 다시 확인해 주세요.");
}
return "apps/register/invitationCodeInput";
}
// 성공: 초대 코드 검증 완료
UserInvitation invitation = invitationOpt.get();
// 만료 확인
if (invitation.getExpiresOn().isBefore(java.time.LocalDateTime.now())) {
model.addAttribute("error", "만료된 초대 코드입니다. 관리자에게 재발송을 요청해 주세요.");
return "apps/register/invitationCodeInput";
}
// 시도 횟수 초기화
session.removeAttribute("invitationCodeAttempts");
session.removeAttribute("invitationCodeLockoutUntil");
// 기존 사용자 여부 확인
Optional<PortalUser> existingUser = portalUserRepository.findPortalUserByEmailAddr(invitation.getInvitationEmail());
if (existingUser.isPresent()) {
// 기존 사용자: 초대 수락 페이지로 이동
return "redirect:/signup/decision?invitation=" + normalizedCode;
} else {
// 신규 사용자: 회원가입 페이지로 이동
return "redirect:/signup/portalUser?invitation=" + normalizedCode;
}
}
}
@@ -6,6 +6,7 @@ import com.eactive.apim.portal.apps.user.mapper.PortalOrgMapper;
import com.eactive.apim.portal.apps.user.mapper.PortalUserMapper;
import com.eactive.apim.portal.common.exception.NotFoundException;
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.common.util.DurationParser;
import com.eactive.apim.portal.common.util.SecurityUtil;
import com.eactive.apim.portal.common.util.StringMaskingUtil;
import com.eactive.apim.portal.invitation.entity.UserInvitation;
@@ -18,23 +19,24 @@ import com.eactive.apim.portal.portaluser.entity.PortalUser;
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.RoleCode;
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.UserStatus;
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import com.eactive.apim.portal.template.service.MessageHandlerService;
import com.eactive.apim.portal.template.service.MessageRecipient;
import com.eactive.apim.portal.user.entity.UserLog;
import com.eactive.apim.portal.user.repository.UserLogRepository;
import java.nio.charset.StandardCharsets;
import lombok.RequiredArgsConstructor;
import org.apache.xerces.impl.dv.util.Base64;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.security.SecureRandom;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.stream.Collectors;
@Service
@@ -48,6 +50,7 @@ public class UserManFacade {
private final UserInvitationRepository userInvitationRepository;
private final MessageHandlerService messageHandlerService;
private final UserLogRepository userLogRepository;
private final PortalPropertyService portalPropertyService;
public Page<PortalUserDTO> getUsers(PortalOrgDTO userOrg, Pageable pageable) {
return portalUserRepository
@@ -91,6 +94,19 @@ public class UserManFacade {
}
public void sendInvitation(PortalUser sender, String emailAddr) {
// 이메일 형식 검증
if (emailAddr == null || emailAddr.trim().isEmpty()) {
throw new IllegalArgumentException("이메일 주소를 입력해주세요.");
}
// 이메일 정규식 검증
String emailPattern = "^[a-zA-Z0-9._%+\\-]+@[a-zA-Z0-9.\\-]+\\.[a-zA-Z]{2,}$";
if (!emailAddr.matches(emailPattern)) {
throw new IllegalArgumentException("올바른 이메일 형식을 입력해주세요.");
}
// 중복 초대 확인
checkDuplicateInvitation(sender.getPortalOrg().getId(), emailAddr);
//기존 사용자인지 확인
Optional<PortalUser> existingUser = portalUserRepository.findPortalUserByEmailAddr(emailAddr);
@@ -104,16 +120,24 @@ public class UserManFacade {
String token = createInvitation(sender, emailAddr);
// {"CRPT_NM":"%corpName%", "MNGR_NM":"%managerName%", "CUST_ID":"%loginId%", "AUTH_NO": "%authNumber%", "NAME": "%loginId%"}
MessageRecipient recipient = new MessageRecipient();
recipient.setUserId(emailAddr);
HashMap<String, Object> params = new HashMap<>();
params.put("manager", sender.getUserName());
params.put("company", sender.getPortalOrg().getOrgName());
params.put("managerName", sender.getUserName());
params.put("corpName", sender.getPortalOrg().getOrgName());
params.put("authNumber", token); // 8-character invitation code
// 기존 사용자인 경우 회원 이름 사용, 비회원인 경우 이메일 사용
if (existingUser.isPresent()) {
params.put("url", "signup/decision?invitation=" + Base64.encode(token.getBytes(StandardCharsets.UTF_8)));
PortalUser user = existingUser.get();
params.put("loginId", user.getUserName());
params.put("NAME", user.getUserName());
params.put("url", "signup/decision?invitation=" + token);
} else {
params.put("url", "signup/portalUser?invitation=" + Base64.encode(token.getBytes(StandardCharsets.UTF_8)));
params.put("loginId", emailAddr);
params.put("NAME", emailAddr);
params.put("url", "signup/portalUser?invitation=" + token);
}
messageHandlerService.publishEvent(UserInvitationEvent.KEY, recipient, params);
@@ -129,18 +153,38 @@ public class UserManFacade {
newInvitation.setInvitationDate(LocalDateTime.now());
newInvitation.setStatus(InvitationStatus.PENDING);
// Generate a unique token (you might want to use a more secure method)
String token = UUID.randomUUID().toString();
// Generate 8-character token for manual entry in closed network
// Excludes confusing characters: O, 0, I, 1
String token = generateEightCharToken();
newInvitation.setToken(token);
// Set expiration date (e.g., 7 days from now)
newInvitation.setExpiresOn(LocalDateTime.now().plusDays(7));
// Set expiration date from PTL_PROPERTY (default: 7 days)
Duration expiration = getInvitationExpiration();
newInvitation.setExpiresOn(LocalDateTime.now().plus(expiration));
// Save the invitation
userInvitationRepository.save(newInvitation);
return token;
}
/**
* 폐쇄망에서 사람이 직접 입력할 수 있는 8글자 토큰 생성
* 혼동되기 쉬운 문자(O, 0, I, 1)를 제외한 대문자 영문자와 숫자 조합
*/
private String generateEightCharToken() {
// 혼동되기 쉬운 문자 제외: O, 0, I, 1
String chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
SecureRandom random = new SecureRandom();
StringBuilder token = new StringBuilder(8);
for (int i = 0; i < 8; i++) {
int index = random.nextInt(chars.length());
token.append(chars.charAt(index));
}
return token.toString();
}
public void assignManager(PortalAuthenticatedUser portalAuthenticatedUser, String targetUserId) {
PortalUser targetUser = portalUserRepository.findById(targetUserId)
.orElseThrow(() -> new NotFoundException("ID [" + targetUserId + "] 사용자를 찾을 수 없습니다. "));
@@ -251,4 +295,78 @@ public class UserManFacade {
})
.collect(Collectors.toList());
}
/**
* 초대 재발송
* 기존 PENDING 또는 EXPIRED 상태의 초대를 취소하고 새로운 초대를 생성합니다.
*/
public void resendInvitation(PortalUser sender, String invitationId) {
UserInvitation existingInvitation = userInvitationRepository.findById(invitationId)
.orElseThrow(() -> new NotFoundException("초대를 찾을 수 없습니다: " + invitationId));
// 권한 확인: 같은 기관의 초대인지 확인
if (!existingInvitation.getOrgId().equals(sender.getPortalOrg().getId())) {
throw new IllegalArgumentException("초대를 재발송할 권한이 없습니다.");
}
// 재발송 가능한 상태인지 확인
if (existingInvitation.getStatus() != InvitationStatus.PENDING
&& existingInvitation.getStatus() != InvitationStatus.EXPIRED) {
throw new IllegalStateException("재발송 가능한 상태가 아닙니다. 현재 상태: " + existingInvitation.getStatus().getDescription());
}
// 기존 초대를 취소 처리
existingInvitation.setStatus(InvitationStatus.CANCELED);
userInvitationRepository.save(existingInvitation);
// 새로운 초대 생성 및 발송
sendInvitation(sender, existingInvitation.getInvitationEmail());
}
/**
* 중복 초대 확인
* 같은 기관에서 같은 이메일로 이미 PENDING 상태인 초대가 있는지 확인
*/
private void checkDuplicateInvitation(String orgId, String emailAddr) {
// 1. 같은 기관에서 PENDING 상태인 초대가 있는지 확인
Optional<UserInvitation> pendingInvitation =
userInvitationRepository.findFirstByInvitationEmailAndOrgIdAndStatus(
emailAddr, orgId, InvitationStatus.PENDING);
if (pendingInvitation.isPresent()) {
throw new IllegalArgumentException("이미 초대가 진행 중인 이메일 주소입니다.");
}
// 2. 다른 기관에서 PENDING 상태인 초대가 있는지 확인
Optional<UserInvitation> otherOrgPendingInvitation =
userInvitationRepository.findFirstByInvitationEmailAndStatus(
emailAddr, InvitationStatus.PENDING);
if (otherOrgPendingInvitation.isPresent()
&& !otherOrgPendingInvitation.get().getOrgId().equals(orgId)) {
throw new IllegalArgumentException("해당 이메일은 다른 기관에서 초대 진행 중입니다.");
}
}
/**
* PTL_PROPERTY에서 초대 만료 기간을 가져옵니다.
* 설정이 없으면 기본값 7일을 반환합니다.
*
* @return 초대 만료 기간
*/
private Duration getInvitationExpiration() {
try {
Map<String, String> properties = portalPropertyService.getPortalPropertiesAsMap("invitation");
String expirationStr = properties.get("expiration");
if (expirationStr != null && !expirationStr.trim().isEmpty()) {
return DurationParser.parseOrDefault(expirationStr, Duration.ofDays(7));
}
} catch (Exception e) {
// 설정을 가져오는 데 실패하면 기본값 사용
}
// 기본값: 7일
return Duration.ofDays(7);
}
}
@@ -0,0 +1,76 @@
package com.eactive.apim.portal.common.util;
import java.time.Duration;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* 시간 표현 문자열을 Duration으로 파싱하는 유틸리티
* 지원 형식: 5m (5분), 2h (2시간), 7d (7일)
*/
public class DurationParser {
private static final Pattern DURATION_PATTERN = Pattern.compile("^(\\d+)([mhd])$");
/**
* 시간 표현 문자열을 Duration으로 변환
*
* @param durationStr 시간 문자열 (예: "5m", "2h", "7d")
* @return Duration 객체
* @throws IllegalArgumentException 형식이 올바르지 않을 경우
*/
public static Duration parse(String durationStr) {
if (durationStr == null || durationStr.trim().isEmpty()) {
throw new IllegalArgumentException("Duration string cannot be null or empty");
}
String normalized = durationStr.trim().toLowerCase();
Matcher matcher = DURATION_PATTERN.matcher(normalized);
if (!matcher.matches()) {
throw new IllegalArgumentException(
"Invalid duration format: " + durationStr +
". Expected format: <number><unit> (e.g., 5m, 2h, 7d)"
);
}
long value = Long.parseLong(matcher.group(1));
String unit = matcher.group(2);
switch (unit) {
case "m":
return Duration.ofMinutes(value);
case "h":
return Duration.ofHours(value);
case "d":
return Duration.ofDays(value);
default:
throw new IllegalArgumentException("Unsupported time unit: " + unit);
}
}
/**
* 시간 표현 문자열을 일(day) 단위로 변환
*
* @param durationStr 시간 문자열 (예: "5m", "2h", "7d")
* @return 일(day) 단위 long 값
*/
public static long parseToDays(String durationStr) {
return parse(durationStr).toDays();
}
/**
* 기본값을 제공하는 안전한 파싱
*
* @param durationStr 시간 문자열
* @param defaultValue 파싱 실패 시 기본값
* @return Duration 객체
*/
public static Duration parseOrDefault(String durationStr, Duration defaultValue) {
try {
return parse(durationStr);
} catch (Exception e) {
return defaultValue;
}
}
}
@@ -1,6 +1,9 @@
package com.eactive.apim.portal.config;
import com.eactive.apim.portal.apps.user.service.PortalUserLogService;
import com.eactive.apim.portal.invitation.entity.UserInvitation;
import com.eactive.apim.portal.invitation.entity.UserInvitationEnums.InvitationStatus;
import com.eactive.apim.portal.invitation.repository.UserInvitationRepository;
import com.eactive.apim.portal.portaluser.entity.PortalUser;
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
import com.eactive.apim.portal.portaluser.entity.UserPasswordHistory;
@@ -34,6 +37,7 @@ public class PortalAuthenticationSuccessHandler implements AuthenticationSuccess
private final PortalUserLogService userLogService;
private final UserPasswordHistoryRepository passwordHistoryRepository;
private final MessageRequestRepository messageRequestRepository;
private final UserInvitationRepository userInvitationRepository;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
@@ -74,6 +78,22 @@ public class PortalAuthenticationSuccessHandler implements AuthenticationSuccess
session.setAttribute("redirectUrl", contextPath + "/new_password");
}
// 초대 코드 확인 - ROLE_USER만 확인
if (user.getRoleCode() == PortalUserEnums.RoleCode.ROLE_USER) {
Optional<UserInvitation> pendingInvitation =
userInvitationRepository.findFirstByInvitationEmailAndStatus(
user.getLoginId(), InvitationStatus.PENDING);
if (pendingInvitation.isPresent()) {
// 만료되지 않은 초대가 있으면 자동으로 초대 수락 페이지로 이동
UserInvitation invitation = pendingInvitation.get();
if (invitation.getExpiresOn().isAfter(LocalDateTime.now())) {
response.sendRedirect(contextPath + "/signup/decision?invitation=" + invitation.getToken());
return;
}
}
}
String decisionToken = (String) request.getSession().getAttribute("decisionToken");
if (decisionToken != null) {
response.sendRedirect(contextPath + "/signup/decision_process");
@@ -70,6 +70,13 @@
<img th:src="@{/img/icon/icon_login_join.png}" alt="">
<a th:href="@{/signup}">회원가입</a>
</li>
<li>
<span>|</span>
</li>
<li class="icon_join">
<img th:src="@{/img/icon/icon_login_join.png}" alt="">
<a th:href="@{/signup/invitation-code}">초대를 받으셨나요?</a>
</li>
</ul>
</div>
<div class="login_list m-only">
@@ -89,6 +96,12 @@
<li class="icon_join">
<a th:href="@{/signup}">회원가입</a>
</li>
<li>
<span>|</span>
</li>
<li class="icon_join">
<a th:href="@{/signup/invitation-code}">초대를 받으셨나요?</a>
</li>
</ul>
</div>
<div th:if="${param.logout}" class="alert alert-info mt-3">
@@ -9,6 +9,21 @@
<h2 class="title">내 정보 관리</h2>
</div>
<!-- 초대 알림 배너 -->
<div th:if="${hasPendingInvitation}" class="invitation_alert_banner" style="background-color: #f0f8ff; border: 1px solid #2196F3; border-radius: 4px; padding: 15px 20px; margin: 20px 0;">
<div style="display: flex; align-items: center; justify-content: space-between;">
<div>
<strong style="color: #1976D2;">법인 회원 초대가 대기 중입니다</strong>
<p style="margin: 5px 0 0 0; color: #666;">기관에서 귀하를 법인 회원으로 초대했습니다. 초대를 수락하면 해당 기관의 법인 회원으로 전환됩니다.</p>
</div>
<div>
<a th:href="@{/signup/decision(invitation=${invitationToken})}" class="common_btn_type_1 blue" style="white-space: nowrap;">
<span>초대 확인</span>
</a>
</div>
</div>
</div>
<div class="inner i_cs h_inner8">
<form id="updateForm" method="post" th:action="@{/mypage/update}" th:object="${user}">
<div class="terms_box user_top">
@@ -0,0 +1,131 @@
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout/kbank_base_layout}">
<body>
<section layout:fragment="contentFragment" class="content">
<div class="content_wrap">
<div class="sub_title2">
<h2 class="title add2">초대 코드 입력</h2>
</div>
<div class="inner i_cs9 h_inner9">
<div class="form_type">
<div class="con_title">
<p>법인 기관으로부터 받으신 8자리 초대 코드를 입력해 주세요.</p>
</div>
<form id="invitationCodeForm" method="post" th:action="@{/signup/invitation-code}">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/>
<div class="form_type_box">
<div class="info1 form_top">
<p class="title">
<span>초대 코드</span>
</p>
<div class="info_line info_add">
<div class="info_box1 add add2">
<!-- <input type="text"-->
<!-- id="invitationCode"-->
<!-- name="invitationCode"-->
<!-- class="common_input_type_1 h_inp"-->
<!-- placeholder="8자리 초대 코드를 입력해 주세요"-->
<!-- maxlength="8"-->
<!-- pattern="[A-Z0-9]{8}"-->
<!-- title="8자리 영문 대문자와 숫자로 입력해주세요"-->
<!-- required-->
<!-- style="text-transform: uppercase;">-->
<input type="text"
id="invitationCode"
name="invitationCode"
class="common_input_type_1 h_inp"
placeholder="8자리 초대 코드를 입력해 주세요"
maxlength="8"
required
style="text-transform: uppercase;">
</div>
</div>
</div>
<div class="info_txt" style="margin-top: 25px;">
<p>※ 초대 코드는 이메일로 전달받으실 수 있습니다.</p>
<p>※ 초대 코드는 대소문자를 구분하지 않으며, 8자리 영문자와 숫자로 구성되어 있습니다.</p>
</div>
</div>
<!-- 에러 메시지 표시 영역 -->
<div th:if="${error}" class="error_message" style="color: #d32f2f; margin: 25px 0 15px 0; padding: 10px; background-color: #ffebee; border-radius: 4px;">
<p th:text="${error}"></p>
</div>
<!-- 브루트포스 방지: 시도 횟수 표시 -->
<div th:if="${attemptsRemaining != null and attemptsRemaining <= 3}"
class="warning_message"
style="color: #f57c00; margin: 25px 0 15px 0; padding: 10px; background-color: #fff3e0; border-radius: 4px;">
<p>남은 시도 횟수: <strong th:text="${attemptsRemaining}"></strong></p>
<p>5회 실패 시 15분간 입력이 제한됩니다.</p>
</div>
<!-- 브루트포스 방지: 계정 잠금 메시지 -->
<div th:if="${lockoutTime != null}"
class="error_message"
style="color: #d32f2f; margin: 25px 0 15px 0; padding: 10px; background-color: #ffebee; border-radius: 4px;">
<p>잘못된 초대 코드를 5회 입력하셨습니다.</p>
<p th:text="${lockoutTime} + ' 후에 다시 시도해 주세요.'"></p>
</div>
<div class="btn_login">
<button type="submit" class="common_btn_type_1 h_btn" th:disabled="${lockoutTime != null}" style="width: 100%; border: none; cursor: pointer;">
<span>확인</span>
</button>
</div>
</form>
<div class="login_list pc-only" style="margin-top: 20px;">
<ul>
<li>
<a th:href="@{/login}">로그인 페이지로 돌아가기</a>
</li>
<li>
<span>|</span>
</li>
<li>
<a th:href="@{/signup}">회원가입</a>
</li>
</ul>
</div>
<div class="login_list m-only" style="margin-top: 20px;">
<ul>
<li>
<a th:href="@{/login}">로그인 페이지로 돌아가기</a>
</li>
<li>
<span>|</span>
</li>
<li>
<a th:href="@{/signup}">회원가입</a>
</li>
</ul>
</div>
</div>
</div>
</div>
</section>
<th:block layout:fragment="contentScript">
<script>
$(document).ready(function() {
// 초대 코드 입력 필드를 대문자로 자동 변환
$('#invitationCode').on('input', function() {
this.value = this.value.toUpperCase();
});
// 성공 메시지가 있으면 표시
var successMsg = [[${success}]];
if (successMsg) {
customPopups.showAlert(successMsg);
}
});
</script>
</th:block>
</body>
</html>
@@ -18,7 +18,7 @@
<p class="tit_txt">
<span th:text="${userName}"></span>님 안녕하세요.<br><br>
<span th:text="${orgName}"></span>에서 귀하를 Kbank API Portal 법인회원으로 초대하였습니다.<br>
<br> Kbank와 함께 금융서비스를 손쉽게 개발해 보세요.<br><br>
<br> 광주은행과 함께 금융서비스를 손쉽게 개발해 보세요.<br><br>
로그인 하여 법인회원 전환을 진행하세요.<br>
</p>
</div>
@@ -67,9 +67,10 @@
<td th:text="${user.maskedEmailAddr}">이메일 아이디</td>
<td>
</td>
<td></td>
<td>초대 대기 중</td>
<td>
<div class="btn_wrap btn_set">
<a href="#none" class="common_btn_type_4 resend_invitation"><span>재발송</span></a>
<a href="#none" class="common_btn_type_4 cancel_invitation"><span>초대취소</span></a>
</div>
</td>
@@ -103,8 +104,10 @@
<div class="contents">
<p class="con_txt2">추가할 이용자 e-mail 주소를 입력해 주세요.</p>
<div class="inp_box">
<input type="text" class="common_input_type_1 w_input" id="new_user_email"
placeholder="이용자 e-mail 주소">
<input type="email" class="common_input_type_1 w_input" id="new_user_email"
placeholder="이용자 e-mail 주소"
pattern="[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}"
title="올바른 이메일 형식을 입력해주세요 (예: user@example.com)">
</div>
</div>
</div>
@@ -208,6 +211,13 @@
return;
}
// 이메일 형식 검증
const emailPattern = /^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$/;
if (!emailPattern.test(email)) {
customPopups.showAlert('올바른 이메일 형식을 입력해주세요. (예: user@example.com)');
return;
}
$.ajax({
url: '/users/invite',
type: 'POST',
@@ -292,6 +302,7 @@
const activateButtons = document.querySelectorAll('.activate_user');
const inactivateButtons = document.querySelectorAll('.inactivate_user');
const cancelInvitationButtons = document.querySelectorAll('.cancel_invitation');
const resendInvitationButtons = document.querySelectorAll('.resend_invitation');
activateButtons.forEach(button => {
button.addEventListener('click', handleUserStatusChange);
@@ -305,6 +316,10 @@
button.addEventListener('click', handleCancelInvitation);
});
resendInvitationButtons.forEach(button => {
button.addEventListener('click', handleResendInvitation);
});
function handleUserStatusChange(event) {
const row = event.target.closest('tr');
const userId = row.querySelector('a').getAttribute('href').split('=')[1];
@@ -361,6 +376,38 @@
});
}
function handleResendInvitation(event) {
const row = event.target.closest('tr');
const userId = row.querySelector('td[data-id]').getAttribute('data-id');
const userName = row.querySelector('td:nth-child(3)').textContent;
customPopups.showConfirm(`${userName}에게 초대를 재발송하시겠습니까?`, function (selection) {
if (selection) {
$.ajax({
url: '/users/resend-invitation',
type: 'POST',
contentType: 'application/json',
data: JSON.stringify({id: userId})
}).done(function (response) {
customPopups.showAlert(response.msg, function () {
location.reload(); // Reload the page to reflect changes
});
}).fail(function (jqXHR, textStatus, errorThrown) {
let errorMessage;
try {
const errorResponse = JSON.parse(jqXHR.responseText);
errorMessage = errorResponse.msg || '초대 재발송 중 오류가 발생했습니다';
} catch (e) {
errorMessage = '초대 재발송 중 오류가 발생했습니다: ' + errorThrown;
}
customPopups.showAlert(errorMessage);
}).always(function () {
$('#customConfirm').hide();
});
}
});
}
});
</script>
</th:block>