Merge branch 'devs/ums' into jenkins_with_weblogic
This commit is contained in:
@@ -2,6 +2,55 @@
|
|||||||
|
|
||||||
광주은행 엔터프라이즈 API 포털 관리 시스템
|
광주은행 엔터프라이즈 API 포털 관리 시스템
|
||||||
|
|
||||||
|
## 프로젝트 클론 (Git Clone)
|
||||||
|
|
||||||
|
아래 스크립트를 복사하여 한 번에 실행하면 모든 프로젝트를 클론합니다:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 변수 설정 (환경에 맞게 수정하세요)
|
||||||
|
GIT_URL="ssh://git@192.168.240.178:18081/eapim"
|
||||||
|
PROJECT_HOME="/c/eactive/workspaces/kjb-eapim-devPortal"
|
||||||
|
DEFAULT_BRANCH="jenkins_with_weblogic"
|
||||||
|
|
||||||
|
# 프로젝트 홈 디렉토리 생성 및 이동
|
||||||
|
mkdir -p "$PROJECT_HOME"
|
||||||
|
cd "$PROJECT_HOME"
|
||||||
|
|
||||||
|
# 메인 프로젝트 클론
|
||||||
|
git clone -b "$DEFAULT_BRANCH" "$GIT_URL/kjb-eapim.git" kjb-eapim
|
||||||
|
|
||||||
|
# 의존 모듈 클론 (kjb-eapim 폴더와 동일 레벨)
|
||||||
|
mkdir -p eapim-online
|
||||||
|
git clone -b "$DEFAULT_BRANCH" "$GIT_URL/elink-online-core-jpa.git" eapim-online/elink-online-core-jpa
|
||||||
|
git clone -b "$DEFAULT_BRANCH" "$GIT_URL/elink-portal-common.git" elink-portal-common
|
||||||
|
git clone -b "$DEFAULT_BRANCH" "$GIT_URL/kjb-safedb.git" kjb-safedb
|
||||||
|
|
||||||
|
# 클론 후 폴더 구조 확인
|
||||||
|
echo "=== 프로젝트 구조 ==="
|
||||||
|
ls -la "$PROJECT_HOME"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 클론 후 폴더 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
$PROJECT_HOME/
|
||||||
|
├── kjb-eapim/ # 메인 프로젝트
|
||||||
|
│ └── eapim-portal/ # 이 프로젝트 (Portal)
|
||||||
|
├── eapim-online/ # Gateway 데이터 모델
|
||||||
|
│ └── elink-online-core-jpa/ # JPA 엔티티 모듈
|
||||||
|
├── elink-portal-common/ # 공통 유틸리티
|
||||||
|
└── kjb-safedb/ # SafeDB 암호화 라이브러리
|
||||||
|
```
|
||||||
|
|
||||||
|
### 의존 관계 (settings.gradle 참조)
|
||||||
|
|
||||||
|
```
|
||||||
|
eapim-portal
|
||||||
|
├── elink-online-core-jpa → ../eapim-online/elink-online-core-jpa
|
||||||
|
├── elink-portal-common → ../elink-portal-common
|
||||||
|
└── kjb-safedb → ../kjb-safedb
|
||||||
|
```
|
||||||
|
|
||||||
## 프로젝트 개요
|
## 프로젝트 개요
|
||||||
|
|
||||||
**EAPIM Portal**은 광주은행의 API 서비스를 관리하고, 사용자 등록, API 키 발급, 문서화, 테스트 기능을 제공하는 웹 기반 플랫폼입니다.
|
**EAPIM Portal**은 광주은행의 API 서비스를 관리하고, 사용자 등록, API 키 발급, 문서화, 테스트 기능을 제공하는 웹 기반 플랫폼입니다.
|
||||||
@@ -372,7 +421,3 @@ docker run -p 8080:8080 eapim-portal:latest
|
|||||||
- **프로젝트 상세 지침**: `CLAUDE.md` (한글)
|
- **프로젝트 상세 지침**: `CLAUDE.md` (한글)
|
||||||
- **사용자 가이드**: `개발자포탈.md` (한글)
|
- **사용자 가이드**: `개발자포탈.md` (한글)
|
||||||
- **빌드 스크립트**: `build-gf63.sh`, `deploy_portal.sh`
|
- **빌드 스크립트**: `build-gf63.sh`, `deploy_portal.sh`
|
||||||
|
|
||||||
## 라이선스
|
|
||||||
|
|
||||||
© 광주은행 (Kwangju Bank)
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,105 @@
|
|||||||
|
flowchart TD
|
||||||
|
Start([법인 관리자가 사용자 초대]) --> GenerateToken[8글자 토큰 생성<br/>예: K7MNPQ2R<br/>UserInvitation 저장<br/>상태: PENDING]
|
||||||
|
|
||||||
|
GenerateToken --> SendEmail[이메일 발송<br/>- 링크 포함<br/>- 8글자 토큰 표시]
|
||||||
|
|
||||||
|
SendEmail --> UserType{사용자 유형}
|
||||||
|
|
||||||
|
%% ========================================
|
||||||
|
%% 신규 회원 플로우
|
||||||
|
%% ========================================
|
||||||
|
UserType -->|신규 회원<br/>미가입자| NewUserPath{초대 확인 방법}
|
||||||
|
|
||||||
|
NewUserPath -->|이메일 링크| EmailLink1[이메일 링크 클릭<br/>/signup/portalUser?invitation=CODE]
|
||||||
|
NewUserPath -->|일반 회원가입| DirectSignup[일반 회원가입 진행<br/>ROLE_USER로 생성]
|
||||||
|
|
||||||
|
EmailLink1 --> SignupPage1[회원가입 페이지<br/>- 이메일 자동 입력됨<br/>- 법인명 표시]
|
||||||
|
SignupPage1 --> RegisterForm[가입 정보 입력<br/>약관 동의]
|
||||||
|
RegisterForm --> RegisterComplete[회원가입 완료<br/>ROLE_CORP_USER로 생성<br/>상태: COMPLETED]
|
||||||
|
RegisterComplete --> NewUserEnd([가입 완료 안내])
|
||||||
|
|
||||||
|
DirectSignup --> Login4[로그인]
|
||||||
|
Login4 --> AfterLogin4[로그인 성공]
|
||||||
|
AfterLogin4 --> CheckInvitation
|
||||||
|
|
||||||
|
%% ========================================
|
||||||
|
%% 기존 회원 플로우
|
||||||
|
%% ========================================
|
||||||
|
UserType -->|기존 회원<br/>ROLE_USER| ExistingUserPath{초대 확인 방법}
|
||||||
|
|
||||||
|
ExistingUserPath -->|이메일 링크<br/>비로그인 상태| EmailLink2[이메일 링크 클릭<br/>/signup/decision?invitation=CODE]
|
||||||
|
ExistingUserPath -->|이메일 링크<br/>로그인 상태| EmailLinkLoggedIn[이메일 링크 클릭<br/>/signup/decision?invitation=CODE]
|
||||||
|
ExistingUserPath -->|직접 로그인| DirectLogin[로그인 페이지에서 로그인]
|
||||||
|
|
||||||
|
%% 비로그인 상태에서 이메일 링크 클릭
|
||||||
|
EmailLink2 --> DecisionPagePublic[초대 수락 페이지<br/>비로그인 상태]
|
||||||
|
DecisionPagePublic --> RedirectLogin2[로그인 유도]
|
||||||
|
RedirectLogin2 --> Login2[로그인]
|
||||||
|
Login2 --> AfterLogin2[로그인 성공]
|
||||||
|
|
||||||
|
%% 로그인 상태에서 이메일 링크 클릭 → 바로 수락 페이지
|
||||||
|
EmailLinkLoggedIn --> DecisionPageDirect[초대 수락 페이지<br/>/signup/decision_process<br/>약관 동의]
|
||||||
|
DecisionPageDirect --> UserDecision
|
||||||
|
|
||||||
|
%% 직접 로그인
|
||||||
|
DirectLogin --> Login3[로그인]
|
||||||
|
Login3 --> AfterLogin3[로그인 성공]
|
||||||
|
|
||||||
|
%% ========================================
|
||||||
|
%% 로그인 후 초대 확인 (PortalAuthenticationSuccessHandler)
|
||||||
|
%% ========================================
|
||||||
|
AfterLogin2 --> CheckInvitation[PortalAuthenticationSuccessHandler<br/>PENDING 초대 확인<br/>findFirstByInvitationEmailAndStatus]
|
||||||
|
AfterLogin3 --> CheckInvitation
|
||||||
|
|
||||||
|
CheckInvitation --> HasInvitation{PENDING 초대 존재?}
|
||||||
|
|
||||||
|
HasInvitation -->|없음| NormalHome[메인 페이지 이동]
|
||||||
|
HasInvitation -->|있음| SaveToSession[세션에 초대 정보 저장<br/>pendingInvitation=true<br/>pendingInvitationToken<br/>pendingInvitationOrgName]
|
||||||
|
|
||||||
|
SaveToSession --> RedirectHome[메인 페이지로 이동]
|
||||||
|
|
||||||
|
%% ========================================
|
||||||
|
%% 메인 페이지 팝업 (index.html)
|
||||||
|
%% ========================================
|
||||||
|
RedirectHome --> ShowPopup[팝업 표시<br/>OOO에서 초대가 도착했습니다<br/>초대를 확인하시겠습니까?]
|
||||||
|
|
||||||
|
ShowPopup --> PopupChoice{사용자 선택}
|
||||||
|
|
||||||
|
PopupChoice -->|확인| GoDecisionPage[초대 수락 페이지 이동<br/>/signup/decision?invitation=TOKEN]
|
||||||
|
PopupChoice -->|취소| StayHome[메인 페이지 유지<br/>다음 로그인 시 팝업 재표시]
|
||||||
|
|
||||||
|
StayHome --> NextLogin[다음 로그인]
|
||||||
|
NextLogin --> CheckInvitation
|
||||||
|
|
||||||
|
GoDecisionPage --> DecisionPage[초대 수락 페이지<br/>/signup/decision_process<br/>약관 동의]
|
||||||
|
|
||||||
|
DecisionPage --> UserDecision{사용자 선택}
|
||||||
|
|
||||||
|
UserDecision -->|수락| AcceptProcess[초대 수락 처리<br/>1. PortalOrg 변경<br/>2. ROLE_CORP_USER 전환<br/>3. 약관 재동의<br/>상태: COMPLETED]
|
||||||
|
UserDecision -->|거절| RejectProcess[초대 거절 처리<br/>상태: REJECTED]
|
||||||
|
|
||||||
|
AcceptProcess --> LogoutPrompt[로그아웃 후 재로그인 안내]
|
||||||
|
LogoutPrompt --> ExistingUserEnd([초대 수락 완료])
|
||||||
|
|
||||||
|
RejectProcess --> RejectEnd([초대 거절 완료<br/>다음 로그인 시 팝업 안 뜸])
|
||||||
|
|
||||||
|
%% ========================================
|
||||||
|
%% 스타일링
|
||||||
|
%% ========================================
|
||||||
|
style Start fill:#e1f5e1
|
||||||
|
style NewUserEnd fill:#c8e6c9
|
||||||
|
style ExistingUserEnd fill:#c8e6c9
|
||||||
|
style RejectEnd fill:#fff9c4
|
||||||
|
style NormalHome fill:#e3f2fd
|
||||||
|
|
||||||
|
style GenerateToken fill:#bbdefb
|
||||||
|
style AcceptProcess fill:#c5cae9
|
||||||
|
style RejectProcess fill:#ffecb3
|
||||||
|
|
||||||
|
style CheckInvitation fill:#ffe0b2
|
||||||
|
style SaveToSession fill:#ffe0b2
|
||||||
|
style ShowPopup fill:#f8bbd0
|
||||||
|
style PopupChoice fill:#f8bbd0
|
||||||
|
|
||||||
|
style StayHome fill:#e1bee7
|
||||||
|
style NextLogin fill:#e1bee7
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
flowchart TD
|
||||||
|
Start([법인 관리자가 사용자 초대 시작]) --> CheckUser{가입된 사용자인가?}
|
||||||
|
|
||||||
|
CheckUser -->|아니오| Error1[에러: 가입된 사용자만 초대 가능]
|
||||||
|
CheckUser -->|예| CheckRole{이미 법인 사용자인가?}
|
||||||
|
|
||||||
|
CheckRole -->|예| Error2[에러: 이미 기관에 등록된 사용자]
|
||||||
|
CheckRole -->|아니오 ROLE_USER| CreateInvitation[8글자 토큰 생성<br/>UserInvitation 저장<br/>상태: PENDING]
|
||||||
|
|
||||||
|
CreateInvitation --> SendEmail[이메일 발송<br/>UserInvitationEvent]
|
||||||
|
|
||||||
|
SendEmail --> UserReceive{사용자가 토큰 받음}
|
||||||
|
|
||||||
|
UserReceive -->|폐쇄망| ManualInput[8글자 토큰 직접 입력<br/>예: K7MNPQ2R]
|
||||||
|
UserReceive -->|이메일 링크| EmailLink[Base64 인코딩된<br/>링크 클릭]
|
||||||
|
|
||||||
|
ManualInput --> DecodeToken[토큰 디코딩<br/>decodeInvitationToken]
|
||||||
|
EmailLink --> DecodeToken
|
||||||
|
|
||||||
|
DecodeToken --> ValidateToken{토큰 유효성 검증}
|
||||||
|
|
||||||
|
ValidateToken -->|무효| Error3[에러: 유효하지 않은 초대]
|
||||||
|
ValidateToken -->|유효 & PENDING| ShowDecision[약관 동의 페이지<br/>/signup/decision]
|
||||||
|
|
||||||
|
ShowDecision --> UserDecision{사용자 선택}
|
||||||
|
|
||||||
|
UserDecision -->|수락| ProcessAccept[processInvitation: accept]
|
||||||
|
UserDecision -->|거절| ProcessReject[processInvitation: reject]
|
||||||
|
|
||||||
|
ProcessAccept --> UpdateUser[1. PortalOrg 변경<br/>2. ROLE_CORP_USER로 전환<br/>3. 약관 재동의 PRIVACY_COLLECT_ORG]
|
||||||
|
UpdateUser --> CompleteInvitation[Invitation 상태:<br/>COMPLETED + completeDate]
|
||||||
|
CompleteInvitation --> Success([초대 수락 완료<br/>로그아웃 후 재로그인])
|
||||||
|
|
||||||
|
ProcessReject --> RejectInvitation[Invitation 상태:<br/>REJECTED + completeDate]
|
||||||
|
RejectInvitation --> End([초대 거절 완료])
|
||||||
|
|
||||||
|
style Start fill:#e1f5e1
|
||||||
|
style Success fill:#c8e6c9
|
||||||
|
style End fill:#fff9c4
|
||||||
|
style Error1 fill:#ffcdd2
|
||||||
|
style Error2 fill:#ffcdd2
|
||||||
|
style Error3 fill:#ffcdd2
|
||||||
|
style CreateInvitation fill:#bbdefb
|
||||||
|
style UpdateUser fill:#c5cae9
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
sequenceDiagram
|
||||||
|
actor 법인관리자
|
||||||
|
participant Portal as Portal UI
|
||||||
|
participant UserManFacade
|
||||||
|
participant UserInvitationRepo as UserInvitation Repository
|
||||||
|
participant MessageService as Message Handler
|
||||||
|
participant Email as Email System
|
||||||
|
actor 초대받은사용자
|
||||||
|
participant AuthHandler as PortalAuthenticationSuccessHandler
|
||||||
|
participant IndexController
|
||||||
|
participant MainPage as 메인 페이지 (index.html)
|
||||||
|
participant UserRegisterController
|
||||||
|
participant UserRegisterFacade
|
||||||
|
participant PortalUserRepo as PortalUser Repository
|
||||||
|
participant PortalOrgRepo as PortalOrg Repository
|
||||||
|
|
||||||
|
%% ========================================
|
||||||
|
%% 1. 초대 발송
|
||||||
|
%% ========================================
|
||||||
|
법인관리자->>Portal: 사용자 이메일 입력<br/>초대 요청
|
||||||
|
Portal->>UserManFacade: sendInvitation(sender, emailAddr)
|
||||||
|
|
||||||
|
UserManFacade->>PortalUserRepo: findByEmailAddr(emailAddr)
|
||||||
|
alt 사용자가 없거나 이미 법인 사용자
|
||||||
|
UserManFacade-->>Portal: 에러 반환
|
||||||
|
Portal-->>법인관리자: 초대 불가 알림
|
||||||
|
else 일반 사용자 ROLE_USER 또는 비회원
|
||||||
|
UserManFacade->>UserManFacade: generateEightCharToken()<br/>예: K7MNPQ2R
|
||||||
|
UserManFacade->>UserInvitationRepo: save(UserInvitation)<br/>status: PENDING<br/>expiresOn: 7일 후
|
||||||
|
UserManFacade->>MessageService: publishEvent(UserInvitationEvent)
|
||||||
|
MessageService->>Email: 초대 이메일 발송<br/>(법인명, 관리자명, 토큰)
|
||||||
|
UserManFacade-->>Portal: 초대 완료
|
||||||
|
Portal-->>법인관리자: 초대 성공 알림
|
||||||
|
end
|
||||||
|
|
||||||
|
Email->>초대받은사용자: 초대 이메일 수신
|
||||||
|
|
||||||
|
%% ========================================
|
||||||
|
%% 2-A. 로그인 상태에서 이메일 링크 클릭 (바로 수락 페이지)
|
||||||
|
%% ========================================
|
||||||
|
alt 로그인 상태에서 이메일 링크 클릭
|
||||||
|
초대받은사용자->>Portal: 이메일 링크 클릭<br/>/signup/decision?invitation=TOKEN
|
||||||
|
Portal->>UserRegisterController: showDecisionPage(invitationToken)
|
||||||
|
UserRegisterController->>UserInvitationRepo: findByToken(decodedToken)
|
||||||
|
UserRegisterController-->>Portal: 약관 동의 페이지
|
||||||
|
Portal-->>초대받은사용자: 초대 수락/거절 화면<br/>(바로 표시, 팝업 없음)
|
||||||
|
Note over 초대받은사용자: 6. 초대 수락/거절 처리로 이동
|
||||||
|
end
|
||||||
|
|
||||||
|
%% ========================================
|
||||||
|
%% 2-B. 로그인 (기존 회원 또는 신규 가입 후)
|
||||||
|
%% ========================================
|
||||||
|
alt 기존 회원 (비로그인 상태)
|
||||||
|
초대받은사용자->>Portal: 로그인
|
||||||
|
else 비회원이 일반 회원가입 후
|
||||||
|
초대받은사용자->>Portal: 회원가입 (ROLE_USER)
|
||||||
|
초대받은사용자->>Portal: 로그인
|
||||||
|
end
|
||||||
|
|
||||||
|
%% ========================================
|
||||||
|
%% 3. 로그인 성공 처리 (AuthenticationSuccessHandler)
|
||||||
|
%% ========================================
|
||||||
|
Portal->>AuthHandler: onAuthenticationSuccess()
|
||||||
|
AuthHandler->>AuthHandler: 이메일 인증, 휴면계정,<br/>비밀번호 만료 체크
|
||||||
|
|
||||||
|
AuthHandler->>UserInvitationRepo: findFirstByInvitationEmailAndStatus<br/>(loginId, PENDING)
|
||||||
|
|
||||||
|
alt PENDING 초대 없음
|
||||||
|
AuthHandler->>Portal: redirect to /
|
||||||
|
Portal-->>초대받은사용자: 메인 페이지 (팝업 없음)
|
||||||
|
else PENDING 초대 있음 & 만료 안됨
|
||||||
|
AuthHandler->>PortalOrgRepo: findById(invitation.orgId)
|
||||||
|
PortalOrgRepo-->>AuthHandler: PortalOrg (기관명)
|
||||||
|
AuthHandler->>AuthHandler: session.setAttribute<br/>("pendingInvitation", true)<br/>("pendingInvitationToken", token)<br/>("pendingInvitationOrgName", orgName)
|
||||||
|
AuthHandler->>Portal: redirect to /
|
||||||
|
end
|
||||||
|
|
||||||
|
%% ========================================
|
||||||
|
%% 4. 메인 페이지 팝업 표시
|
||||||
|
%% ========================================
|
||||||
|
Portal->>IndexController: GET /
|
||||||
|
IndexController->>MainPage: render index.html
|
||||||
|
|
||||||
|
MainPage->>MainPage: 세션에서 pendingInvitation 확인
|
||||||
|
|
||||||
|
alt pendingInvitation == true
|
||||||
|
MainPage-->>초대받은사용자: 팝업 표시<br/>"[기관명]에서 초대가 도착했습니다.<br/>초대를 확인하시겠습니까?"
|
||||||
|
end
|
||||||
|
|
||||||
|
%% ========================================
|
||||||
|
%% 5. 사용자 팝업 선택
|
||||||
|
%% ========================================
|
||||||
|
alt 팝업에서 "확인" 클릭
|
||||||
|
초대받은사용자->>Portal: /signup/decision?invitation=TOKEN
|
||||||
|
Portal->>UserRegisterController: showDecisionPage(invitationToken)
|
||||||
|
UserRegisterController->>UserInvitationRepo: findByToken(decodedToken)
|
||||||
|
UserRegisterController-->>Portal: 약관 동의 페이지
|
||||||
|
Portal-->>초대받은사용자: 초대 수락/거절 화면
|
||||||
|
else 팝업에서 "취소" 클릭
|
||||||
|
Note over 초대받은사용자,MainPage: 메인 페이지 유지<br/>다음 로그인 시 팝업 재표시
|
||||||
|
end
|
||||||
|
|
||||||
|
%% ========================================
|
||||||
|
%% 6. 초대 수락/거절 처리
|
||||||
|
%% ========================================
|
||||||
|
초대받은사용자->>Portal: 수락 or 거절 선택
|
||||||
|
Portal->>UserRegisterController: processInvitation(action)
|
||||||
|
UserRegisterController->>UserRegisterFacade: processInvitation(action, invitation)
|
||||||
|
|
||||||
|
alt 수락
|
||||||
|
UserRegisterFacade->>PortalUserRepo: findByEmailAddr()
|
||||||
|
UserRegisterFacade->>PortalUserRepo: updateUserToCorpUser()<br/>(PortalOrg 변경<br/>ROLE_CORP_USER 전환)
|
||||||
|
UserRegisterFacade->>UserInvitationRepo: save(invitation)<br/>status: COMPLETED<br/>completeDate: now()
|
||||||
|
UserRegisterFacade-->>Portal: 수락 성공
|
||||||
|
Portal-->>초대받은사용자: 성공 메시지<br/>로그아웃 후 재로그인 안내
|
||||||
|
Note over 초대받은사용자: 다음 로그인 시<br/>PENDING 초대 없음 → 팝업 안 뜸
|
||||||
|
else 거절
|
||||||
|
UserRegisterFacade->>UserInvitationRepo: save(invitation)<br/>status: REJECTED<br/>completeDate: now()
|
||||||
|
UserRegisterFacade-->>Portal: 거절 처리 완료
|
||||||
|
Portal-->>초대받은사용자: 거절 완료 메시지
|
||||||
|
Note over 초대받은사용자: 다음 로그인 시<br/>REJECTED 상태 → 팝업 안 뜸
|
||||||
|
end
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
stateDiagram-v2
|
||||||
|
[*] --> 초대생성: 법인관리자가 초대 시작
|
||||||
|
|
||||||
|
초대생성 --> PENDING: 토큰 생성 및 저장
|
||||||
|
|
||||||
|
PENDING --> 이메일발송: 초대 이메일 발송
|
||||||
|
|
||||||
|
state 로그인후팝업 {
|
||||||
|
이메일발송 --> 로그인: 사용자 로그인
|
||||||
|
로그인 --> 초대확인: AuthSuccessHandler
|
||||||
|
초대확인 --> 세션저장: 세션에 초대 정보 저장
|
||||||
|
세션저장 --> 팝업표시: 메인 페이지 팝업
|
||||||
|
팝업표시 --> 팝업확인: 확인 클릭
|
||||||
|
팝업표시 --> 팝업취소: 취소 클릭
|
||||||
|
팝업취소 --> 로그인: 다음 로그인 시 재표시
|
||||||
|
}
|
||||||
|
|
||||||
|
팝업확인 --> 약관동의: 수락 페이지 이동
|
||||||
|
|
||||||
|
약관동의 --> COMPLETED: 수락
|
||||||
|
약관동의 --> REJECTED: 거절
|
||||||
|
|
||||||
|
COMPLETED --> [*]: 초대 완료
|
||||||
|
REJECTED --> [*]: 초대 거절
|
||||||
|
PENDING --> CANCELED: 관리자 취소
|
||||||
|
PENDING --> EXPIRED: 만료일 경과
|
||||||
|
CANCELED --> [*]: 초대 취소
|
||||||
|
EXPIRED --> [*]: 초대 만료
|
||||||
|
|
||||||
|
note right of PENDING
|
||||||
|
PENDING 상태일 때만
|
||||||
|
로그인 시 팝업 표시
|
||||||
|
end note
|
||||||
|
|
||||||
|
note right of REJECTED
|
||||||
|
REJECTED 상태가 되면
|
||||||
|
더 이상 팝업 표시 안 함
|
||||||
|
end note
|
||||||
|
|
||||||
|
note right of COMPLETED
|
||||||
|
COMPLETED 상태가 되면
|
||||||
|
더 이상 팝업 표시 안 함
|
||||||
|
ROLE_CORP_USER로 전환됨
|
||||||
|
end note
|
||||||
@@ -19,6 +19,7 @@ import org.springframework.ui.Model;
|
|||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestParam;
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
|
||||||
|
|
||||||
@Controller
|
@Controller
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class IndexController {
|
public class IndexController {
|
||||||
|
|||||||
@@ -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.apps.user.facade.UserFacade;
|
||||||
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
||||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
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;
|
||||||
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.RoleCode;
|
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.RoleCode;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
@@ -47,6 +50,7 @@ public class AccountController {
|
|||||||
private final OrgRegisterFacade orgRegisterFacade;
|
private final OrgRegisterFacade orgRegisterFacade;
|
||||||
private final AgreementsFacade agreementsFacade;
|
private final AgreementsFacade agreementsFacade;
|
||||||
private final com.eactive.apim.portal.apps.user.facade.AuthFacade authFacade;
|
private final com.eactive.apim.portal.apps.user.facade.AuthFacade authFacade;
|
||||||
|
private final UserInvitationRepository userInvitationRepository;
|
||||||
|
|
||||||
|
|
||||||
@PostMapping("/confirm_password")
|
@PostMapping("/confirm_password")
|
||||||
@@ -122,7 +126,8 @@ public class AccountController {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// 현재 로그인된 사용자의 정보를 가져옴
|
// 현재 로그인된 사용자의 정보를 가져옴
|
||||||
PortalUserDTO user = userFacade.findById(SecurityUtil.getPortalAuthenticatedUser().getId());
|
PortalAuthenticatedUser currentUser = SecurityUtil.getPortalAuthenticatedUser();
|
||||||
|
PortalUserDTO user = userFacade.findById(currentUser.getId());
|
||||||
|
|
||||||
// 개별 필드들을 모델에 추가
|
// 개별 필드들을 모델에 추가
|
||||||
mav.addObject("loginId", user.getLoginId());
|
mav.addObject("loginId", user.getLoginId());
|
||||||
@@ -132,6 +137,20 @@ public class AccountController {
|
|||||||
// 기존 user 객체도 유지 (다른 곳에서 필요할 수 있으므로)
|
// 기존 user 객체도 유지 (다른 곳에서 필요할 수 있으므로)
|
||||||
mav.addObject("user", 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 목록을 분리하여 모델에 추가 (법인 관리자인 경우만)
|
// IP 목록을 분리하여 모델에 추가 (법인 관리자인 경우만)
|
||||||
if (user.getRoleCode() == PortalUserEnums.RoleCode.ROLE_CORP_MANAGER
|
if (user.getRoleCode() == PortalUserEnums.RoleCode.ROLE_CORP_MANAGER
|
||||||
&& user.getPortalOrg() != null
|
&& user.getPortalOrg() != null
|
||||||
@@ -143,7 +162,7 @@ public class AccountController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 사용자의 RoleCode에 따라 다른 페이지로 이동
|
// 사용자의 RoleCode에 따라 다른 페이지로 이동
|
||||||
switch (SecurityUtil.getPortalAuthenticatedUser().getRoleCode()) {
|
switch (currentUser.getRoleCode()) {
|
||||||
case ROLE_USER:
|
case ROLE_USER:
|
||||||
mav.setViewName("apps/mypage/updatePersonalUser");
|
mav.setViewName("apps/mypage/updatePersonalUser");
|
||||||
break;
|
break;
|
||||||
|
|||||||
+20
-1
@@ -43,8 +43,20 @@ public class UserManRestController {
|
|||||||
//5. register new user
|
//5. register new user
|
||||||
@PostMapping("/invite")
|
@PostMapping("/invite")
|
||||||
public ResponseDTO sendInvitation(@RequestBody PortalUserDTO newUser) {
|
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", "요청 메일이 발송되었습니다.");
|
return new ResponseDTO(200, "SUCCESS", "요청 메일이 발송되었습니다.");
|
||||||
}
|
}
|
||||||
@@ -56,4 +68,11 @@ public class UserManRestController {
|
|||||||
|
|
||||||
return new ResponseDTO(200, "SUCCESS", "변경되었습니다. 확인 버튼을 누르시면 계정을 로그아웃 합니다.");
|
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", "초대가 재발송되었습니다.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+126
-2
@@ -71,7 +71,8 @@ public class UserRegisterController {
|
|||||||
String[] parts = null;
|
String[] parts = null;
|
||||||
|
|
||||||
if (invitationToken != null) {
|
if (invitationToken != null) {
|
||||||
String decodedToken = new String(Base64.decode(invitationToken));
|
// 8글자 토큰 사용
|
||||||
|
String decodedToken = decodeInvitationToken(invitationToken);
|
||||||
session.setAttribute("invitationToken", decodedToken);
|
session.setAttribute("invitationToken", decodedToken);
|
||||||
Optional<UserInvitation> invitation = userInvitationRepository.findByToken(decodedToken);
|
Optional<UserInvitation> invitation = userInvitationRepository.findByToken(decodedToken);
|
||||||
if (invitation.isPresent() && invitation.get().getStatus() == UserInvitationEnums.InvitationStatus.PENDING) {
|
if (invitation.isPresent() && invitation.get().getStatus() == UserInvitationEnums.InvitationStatus.PENDING) {
|
||||||
@@ -153,7 +154,8 @@ public class UserRegisterController {
|
|||||||
return "redirect:/";
|
return "redirect:/";
|
||||||
}
|
}
|
||||||
|
|
||||||
String decodedToken = new String(Base64.decode(invitationToken));
|
// 8글자 토큰 사용
|
||||||
|
String decodedToken = decodeInvitationToken(invitationToken);
|
||||||
session.setAttribute("decisionToken", decodedToken);
|
session.setAttribute("decisionToken", decodedToken);
|
||||||
|
|
||||||
Optional<UserInvitation> invitation = userInvitationRepository.findByToken(decodedToken);
|
Optional<UserInvitation> invitation = userInvitationRepository.findByToken(decodedToken);
|
||||||
@@ -183,6 +185,7 @@ public class UserRegisterController {
|
|||||||
// 이메일 대신 사용자 이름을 모델에 추가
|
// 이메일 대신 사용자 이름을 모델에 추가
|
||||||
model.addAttribute("userName", portalUser.get().getUserName());
|
model.addAttribute("userName", portalUser.get().getUserName());
|
||||||
model.addAttribute("orgName", org.get().getOrgName());
|
model.addAttribute("orgName", org.get().getOrgName());
|
||||||
|
model.addAttribute("invitationCode", decodedToken);
|
||||||
model.addAttribute("isInvited", true);
|
model.addAttribute("isInvited", true);
|
||||||
|
|
||||||
return "apps/register/userDecisionAccept";
|
return "apps/register/userDecisionAccept";
|
||||||
@@ -213,6 +216,7 @@ public class UserRegisterController {
|
|||||||
|
|
||||||
model.addAttribute("userName", SecurityUtil.getPortalAuthenticatedUser().getUsername());
|
model.addAttribute("userName", SecurityUtil.getPortalAuthenticatedUser().getUsername());
|
||||||
model.addAttribute("orgName", org.get().getOrgName());
|
model.addAttribute("orgName", org.get().getOrgName());
|
||||||
|
model.addAttribute("invitationCode", invitation.get().getToken());
|
||||||
model.addAttribute("termsOfUse", agreementsFacade.getAgreement("TERMS_OF_USE"));
|
model.addAttribute("termsOfUse", agreementsFacade.getAgreement("TERMS_OF_USE"));
|
||||||
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT_ORG"));
|
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT_ORG"));
|
||||||
model.addAttribute("registrationType", "corporate");
|
model.addAttribute("registrationType", "corporate");
|
||||||
@@ -294,4 +298,124 @@ public class UserRegisterController {
|
|||||||
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT_IND"));
|
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT_IND"));
|
||||||
model.addAttribute("msgType", "sms");
|
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.apps.user.mapper.PortalUserMapper;
|
||||||
import com.eactive.apim.portal.common.exception.NotFoundException;
|
import com.eactive.apim.portal.common.exception.NotFoundException;
|
||||||
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
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.SecurityUtil;
|
||||||
import com.eactive.apim.portal.common.util.StringMaskingUtil;
|
import com.eactive.apim.portal.common.util.StringMaskingUtil;
|
||||||
import com.eactive.apim.portal.invitation.entity.UserInvitation;
|
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.RoleCode;
|
||||||
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.UserStatus;
|
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.UserStatus;
|
||||||
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
|
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.MessageHandlerService;
|
||||||
import com.eactive.apim.portal.template.service.MessageRecipient;
|
import com.eactive.apim.portal.template.service.MessageRecipient;
|
||||||
import com.eactive.apim.portal.user.entity.UserLog;
|
import com.eactive.apim.portal.user.entity.UserLog;
|
||||||
import com.eactive.apim.portal.user.repository.UserLogRepository;
|
import com.eactive.apim.portal.user.repository.UserLogRepository;
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.apache.xerces.impl.dv.util.Base64;
|
|
||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.security.SecureRandom;
|
||||||
|
import java.time.Duration;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@@ -48,6 +50,7 @@ public class UserManFacade {
|
|||||||
private final UserInvitationRepository userInvitationRepository;
|
private final UserInvitationRepository userInvitationRepository;
|
||||||
private final MessageHandlerService messageHandlerService;
|
private final MessageHandlerService messageHandlerService;
|
||||||
private final UserLogRepository userLogRepository;
|
private final UserLogRepository userLogRepository;
|
||||||
|
private final PortalPropertyService portalPropertyService;
|
||||||
|
|
||||||
public Page<PortalUserDTO> getUsers(PortalOrgDTO userOrg, Pageable pageable) {
|
public Page<PortalUserDTO> getUsers(PortalOrgDTO userOrg, Pageable pageable) {
|
||||||
return portalUserRepository
|
return portalUserRepository
|
||||||
@@ -91,6 +94,19 @@ public class UserManFacade {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public void sendInvitation(PortalUser sender, String emailAddr) {
|
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);
|
Optional<PortalUser> existingUser = portalUserRepository.findPortalUserByEmailAddr(emailAddr);
|
||||||
@@ -104,16 +120,24 @@ public class UserManFacade {
|
|||||||
|
|
||||||
String token = createInvitation(sender, emailAddr);
|
String token = createInvitation(sender, emailAddr);
|
||||||
|
|
||||||
|
// {"CRPT_NM":"%corpName%", "MNGR_NM":"%managerName%", "CUST_ID":"%loginId%", "AUTH_NO": "%authNumber%", "NAME": "%loginId%"}
|
||||||
MessageRecipient recipient = new MessageRecipient();
|
MessageRecipient recipient = new MessageRecipient();
|
||||||
recipient.setUserId(emailAddr);
|
recipient.setUserId(emailAddr);
|
||||||
HashMap<String, Object> params = new HashMap<>();
|
HashMap<String, Object> params = new HashMap<>();
|
||||||
params.put("manager", sender.getUserName());
|
params.put("managerName", sender.getUserName());
|
||||||
params.put("company", sender.getPortalOrg().getOrgName());
|
params.put("corpName", sender.getPortalOrg().getOrgName());
|
||||||
|
params.put("authNumber", token); // 8-character invitation code
|
||||||
|
|
||||||
|
// 기존 사용자인 경우 회원 이름 사용, 비회원인 경우 이메일 사용
|
||||||
if (existingUser.isPresent()) {
|
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 {
|
} 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);
|
messageHandlerService.publishEvent(UserInvitationEvent.KEY, recipient, params);
|
||||||
@@ -129,18 +153,38 @@ public class UserManFacade {
|
|||||||
newInvitation.setInvitationDate(LocalDateTime.now());
|
newInvitation.setInvitationDate(LocalDateTime.now());
|
||||||
newInvitation.setStatus(InvitationStatus.PENDING);
|
newInvitation.setStatus(InvitationStatus.PENDING);
|
||||||
|
|
||||||
// Generate a unique token (you might want to use a more secure method)
|
// Generate 8-character token for manual entry in closed network
|
||||||
String token = UUID.randomUUID().toString();
|
// Excludes confusing characters: O, 0, I, 1
|
||||||
|
String token = generateEightCharToken();
|
||||||
newInvitation.setToken(token);
|
newInvitation.setToken(token);
|
||||||
|
|
||||||
// Set expiration date (e.g., 7 days from now)
|
// Set expiration date from PTL_PROPERTY (default: 7 days)
|
||||||
newInvitation.setExpiresOn(LocalDateTime.now().plusDays(7));
|
Duration expiration = getInvitationExpiration();
|
||||||
|
newInvitation.setExpiresOn(LocalDateTime.now().plus(expiration));
|
||||||
|
|
||||||
// Save the invitation
|
// Save the invitation
|
||||||
userInvitationRepository.save(newInvitation);
|
userInvitationRepository.save(newInvitation);
|
||||||
return token;
|
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) {
|
public void assignManager(PortalAuthenticatedUser portalAuthenticatedUser, String targetUserId) {
|
||||||
PortalUser targetUser = portalUserRepository.findById(targetUserId)
|
PortalUser targetUser = portalUserRepository.findById(targetUserId)
|
||||||
.orElseThrow(() -> new NotFoundException("ID [" + targetUserId + "] 사용자를 찾을 수 없습니다. "));
|
.orElseThrow(() -> new NotFoundException("ID [" + targetUserId + "] 사용자를 찾을 수 없습니다. "));
|
||||||
@@ -251,4 +295,78 @@ public class UserManFacade {
|
|||||||
})
|
})
|
||||||
.collect(Collectors.toList());
|
.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,11 @@
|
|||||||
package com.eactive.apim.portal.config;
|
package com.eactive.apim.portal.config;
|
||||||
|
|
||||||
|
import com.eactive.apim.portal.apps.user.repository.PortalOrgRepository;
|
||||||
import com.eactive.apim.portal.apps.user.service.PortalUserLogService;
|
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.portalorg.entity.PortalOrg;
|
||||||
import com.eactive.apim.portal.portaluser.entity.PortalUser;
|
import com.eactive.apim.portal.portaluser.entity.PortalUser;
|
||||||
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
|
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
|
||||||
import com.eactive.apim.portal.portaluser.entity.UserPasswordHistory;
|
import com.eactive.apim.portal.portaluser.entity.UserPasswordHistory;
|
||||||
@@ -34,6 +39,8 @@ public class PortalAuthenticationSuccessHandler implements AuthenticationSuccess
|
|||||||
private final PortalUserLogService userLogService;
|
private final PortalUserLogService userLogService;
|
||||||
private final UserPasswordHistoryRepository passwordHistoryRepository;
|
private final UserPasswordHistoryRepository passwordHistoryRepository;
|
||||||
private final MessageRequestRepository messageRequestRepository;
|
private final MessageRequestRepository messageRequestRepository;
|
||||||
|
private final UserInvitationRepository userInvitationRepository;
|
||||||
|
private final PortalOrgRepository portalOrgRepository;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
|
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
|
||||||
@@ -74,6 +81,27 @@ public class PortalAuthenticationSuccessHandler implements AuthenticationSuccess
|
|||||||
session.setAttribute("redirectUrl", contextPath + "/new_password");
|
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())) {
|
||||||
|
// 세션에 초대 정보 저장 (메인 페이지에서 팝업으로 표시)
|
||||||
|
session.setAttribute("pendingInvitation", true);
|
||||||
|
session.setAttribute("pendingInvitationToken", invitation.getToken());
|
||||||
|
// orgId로 기관명 조회
|
||||||
|
String orgName = portalOrgRepository.findById(invitation.getOrgId())
|
||||||
|
.map(PortalOrg::getOrgName)
|
||||||
|
.orElse("알 수 없는 기관");
|
||||||
|
session.setAttribute("pendingInvitationOrgName", orgName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
String decisionToken = (String) request.getSession().getAttribute("decisionToken");
|
String decisionToken = (String) request.getSession().getAttribute("decisionToken");
|
||||||
if (decisionToken != null) {
|
if (decisionToken != null) {
|
||||||
response.sendRedirect(contextPath + "/signup/decision_process");
|
response.sendRedirect(contextPath + "/signup/decision_process");
|
||||||
|
|||||||
@@ -89,6 +89,12 @@
|
|||||||
<li class="icon_join">
|
<li class="icon_join">
|
||||||
<a th:href="@{/signup}">회원가입</a>
|
<a th:href="@{/signup}">회원가입</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<span>|</span>
|
||||||
|
</li>
|
||||||
|
<li class="icon_join">
|
||||||
|
<a th:href="@{/signup/invitation-code}">초대를 받으셨나요?</a>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div th:if="${param.logout}" class="alert alert-info mt-3">
|
<div th:if="${param.logout}" class="alert alert-info mt-3">
|
||||||
|
|||||||
@@ -299,6 +299,20 @@
|
|||||||
customPopups.hideAlert('#customAlert');
|
customPopups.hideAlert('#customAlert');
|
||||||
window.location.href = redirectUrl;
|
window.location.href = redirectUrl;
|
||||||
});
|
});
|
||||||
|
return; // 다른 체크 중단
|
||||||
|
}
|
||||||
|
|
||||||
|
// 초대 대기 확인 (로그인할 때마다 표시)
|
||||||
|
const pendingInvitation = [[${session.pendingInvitation}]];
|
||||||
|
if (pendingInvitation) {
|
||||||
|
const invitationToken = /*[[${session.pendingInvitationToken}]]*/ '';
|
||||||
|
const orgName = /*[[${session.pendingInvitationOrgName}]]*/ '';
|
||||||
|
|
||||||
|
customPopups.showConfirm(`<strong>${orgName}</strong>에서 초대가 도착했습니다.<br>초대를 확인하시겠습니까?`, function (selection) {
|
||||||
|
if (selection) {
|
||||||
|
window.location.href = '/signup/decision?invitation=' + invitationToken;
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -9,6 +9,21 @@
|
|||||||
<h2 class="title">내 정보 관리</h2>
|
<h2 class="title">내 정보 관리</h2>
|
||||||
</div>
|
</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">
|
<div class="inner i_cs h_inner8">
|
||||||
<form id="updateForm" method="post" th:action="@{/mypage/update}" th:object="${user}">
|
<form id="updateForm" method="post" th:action="@{/mypage/update}" th:object="${user}">
|
||||||
<div class="terms_box user_top">
|
<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,10 +18,19 @@
|
|||||||
<p class="tit_txt">
|
<p class="tit_txt">
|
||||||
<span th:text="${userName}"></span>님 안녕하세요.<br><br>
|
<span th:text="${userName}"></span>님 안녕하세요.<br><br>
|
||||||
<span th:text="${orgName}"></span>에서 귀하를 Kbank API Portal 법인회원으로 초대하였습니다.<br>
|
<span th:text="${orgName}"></span>에서 귀하를 Kbank API Portal 법인회원으로 초대하였습니다.<br>
|
||||||
<br> Kbank와 함께 금융서비스를 손쉽게 개발해 보세요.<br><br>
|
<br> 광주은행과 함께 금융서비스를 손쉽게 개발해 보세요.<br><br>
|
||||||
로그인 하여 법인회원 전환을 진행하세요.<br>
|
로그인 하여 법인회원 전환을 진행하세요.<br>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="info_box" style="margin-top: 20px; padding: 15px; background-color: #f8f9fa; border: 1px solid #dee2e6; border-radius: 8px;">
|
||||||
|
<p style="margin: 0; font-size: 14px; color: #666;">
|
||||||
|
<strong>초대코드:</strong>
|
||||||
|
<span th:text="${invitationCode}" style="font-family: monospace; font-size: 16px; font-weight: bold; color: #333; letter-spacing: 2px;"></span>
|
||||||
|
</p>
|
||||||
|
<p style="margin: 8px 0 0 0; font-size: 12px; color: #999;">
|
||||||
|
이메일로 받은 초대코드와 일치하는지 확인해 주세요.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<div class="btn_application m_top">
|
<div class="btn_application m_top">
|
||||||
<button type="button" onclick="submitForm('reject')" class="common_btn_type_1 gray">
|
<button type="button" onclick="submitForm('reject')" class="common_btn_type_1 gray">
|
||||||
<span>취소</span>
|
<span>취소</span>
|
||||||
|
|||||||
@@ -27,6 +27,15 @@
|
|||||||
초대를 수락하시면 법인회원으로 전환됩니다.<br>
|
초대를 수락하시면 법인회원으로 전환됩니다.<br>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="info_box" style="margin-top: 20px; padding: 15px; background-color: #f8f9fa; border: 1px solid #dee2e6; border-radius: 8px;">
|
||||||
|
<p style="margin: 0; font-size: 14px; color: #666;">
|
||||||
|
<strong>초대코드:</strong>
|
||||||
|
<span th:text="${invitationCode}" style="font-family: monospace; font-size: 16px; font-weight: bold; color: #333; letter-spacing: 2px;"></span>
|
||||||
|
</p>
|
||||||
|
<p style="margin: 8px 0 0 0; font-size: 12px; color: #999;">
|
||||||
|
이메일로 받은 초대코드와 일치하는지 확인해 주세요.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<div class="btn_application m_top">
|
<div class="btn_application m_top">
|
||||||
<button type="button" onclick="submitForm('reject')" class="common_btn_type_1 gray">
|
<button type="button" onclick="submitForm('reject')" class="common_btn_type_1 gray">
|
||||||
<span>거절</span>
|
<span>거절</span>
|
||||||
|
|||||||
@@ -67,9 +67,10 @@
|
|||||||
<td th:text="${user.maskedEmailAddr}">이메일 아이디</td>
|
<td th:text="${user.maskedEmailAddr}">이메일 아이디</td>
|
||||||
<td>
|
<td>
|
||||||
</td>
|
</td>
|
||||||
<td></td>
|
<td>초대 대기 중</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="btn_wrap btn_set">
|
<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>
|
<a href="#none" class="common_btn_type_4 cancel_invitation"><span>초대취소</span></a>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -103,8 +104,10 @@
|
|||||||
<div class="contents">
|
<div class="contents">
|
||||||
<p class="con_txt2">추가할 이용자 e-mail 주소를 입력해 주세요.</p>
|
<p class="con_txt2">추가할 이용자 e-mail 주소를 입력해 주세요.</p>
|
||||||
<div class="inp_box">
|
<div class="inp_box">
|
||||||
<input type="text" class="common_input_type_1 w_input" id="new_user_email"
|
<input type="email" class="common_input_type_1 w_input" id="new_user_email"
|
||||||
placeholder="이용자 e-mail 주소">
|
placeholder="이용자 e-mail 주소"
|
||||||
|
pattern="[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}"
|
||||||
|
title="올바른 이메일 형식을 입력해주세요 (예: user@example.com)">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -208,6 +211,13 @@
|
|||||||
return;
|
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({
|
$.ajax({
|
||||||
url: '/users/invite',
|
url: '/users/invite',
|
||||||
type: 'POST',
|
type: 'POST',
|
||||||
@@ -292,6 +302,7 @@
|
|||||||
const activateButtons = document.querySelectorAll('.activate_user');
|
const activateButtons = document.querySelectorAll('.activate_user');
|
||||||
const inactivateButtons = document.querySelectorAll('.inactivate_user');
|
const inactivateButtons = document.querySelectorAll('.inactivate_user');
|
||||||
const cancelInvitationButtons = document.querySelectorAll('.cancel_invitation');
|
const cancelInvitationButtons = document.querySelectorAll('.cancel_invitation');
|
||||||
|
const resendInvitationButtons = document.querySelectorAll('.resend_invitation');
|
||||||
|
|
||||||
activateButtons.forEach(button => {
|
activateButtons.forEach(button => {
|
||||||
button.addEventListener('click', handleUserStatusChange);
|
button.addEventListener('click', handleUserStatusChange);
|
||||||
@@ -305,6 +316,10 @@
|
|||||||
button.addEventListener('click', handleCancelInvitation);
|
button.addEventListener('click', handleCancelInvitation);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
resendInvitationButtons.forEach(button => {
|
||||||
|
button.addEventListener('click', handleResendInvitation);
|
||||||
|
});
|
||||||
|
|
||||||
function handleUserStatusChange(event) {
|
function handleUserStatusChange(event) {
|
||||||
const row = event.target.closest('tr');
|
const row = event.target.closest('tr');
|
||||||
const userId = row.querySelector('a').getAttribute('href').split('=')[1];
|
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>
|
</script>
|
||||||
</th:block>
|
</th:block>
|
||||||
|
|||||||
Reference in New Issue
Block a user