법인 회원 초대 대응
This commit is contained in:
@@ -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;
|
||||
|
||||
+20
-1
@@ -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", "초대가 재발송되었습니다.");
|
||||
}
|
||||
}
|
||||
|
||||
+124
-2
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user