Compare commits
6 Commits
87369ce7f7
...
929980c089
| Author | SHA1 | Date | |
|---|---|---|---|
| 929980c089 | |||
| d4d5d66727 | |||
| d3e6249a41 | |||
| 68f8c07fc0 | |||
| 80740a9543 | |||
| cdd68f48f0 |
@@ -13,6 +13,9 @@ import com.eactive.apim.portal.apps.app.dto.AppRequestDTO;
|
|||||||
import com.eactive.apim.portal.apps.app.dto.ClientDTO;
|
import com.eactive.apim.portal.apps.app.dto.ClientDTO;
|
||||||
import com.eactive.apim.portal.apps.app.service.AdminGatewayClient;
|
import com.eactive.apim.portal.apps.app.service.AdminGatewayClient;
|
||||||
import com.eactive.apim.portal.apps.app.service.AppServiceFacade;
|
import com.eactive.apim.portal.apps.app.service.AppServiceFacade;
|
||||||
|
import com.eactive.apim.portal.apps.auth.twofactor.StepUpProtectedPaths;
|
||||||
|
import com.eactive.apim.portal.apps.auth.twofactor.TwoFactorProperties;
|
||||||
|
import com.eactive.apim.portal.apps.auth.twofactor.TwoFactorService;
|
||||||
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
||||||
import com.eactive.apim.portal.common.util.ApiServiceHelper;
|
import com.eactive.apim.portal.common.util.ApiServiceHelper;
|
||||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||||
@@ -82,6 +85,8 @@ public class MyAppController {
|
|||||||
private final ApiServiceHelper apiServiceHelper;
|
private final ApiServiceHelper apiServiceHelper;
|
||||||
private final FileTypeDetector fileTypeDetector;
|
private final FileTypeDetector fileTypeDetector;
|
||||||
private final AdminGatewayClient adminGatewayClient;
|
private final AdminGatewayClient adminGatewayClient;
|
||||||
|
private final TwoFactorService twoFactorService;
|
||||||
|
private final TwoFactorProperties twoFactorProperties;
|
||||||
|
|
||||||
private static final long MAX_APP_ICON_BYTES = 2L * 1024 * 1024; // 2MB
|
private static final long MAX_APP_ICON_BYTES = 2L * 1024 * 1024; // 2MB
|
||||||
|
|
||||||
@@ -893,6 +898,8 @@ public class MyAppController {
|
|||||||
setupStepModel(model, 2);
|
setupStepModel(model, 2);
|
||||||
model.addAttribute("apiServices", apiServices);
|
model.addAttribute("apiServices", apiServices);
|
||||||
model.addAttribute("modification", modification);
|
model.addAttribute("modification", modification);
|
||||||
|
// 최종 반영(저장) 직전 2FA 필요 여부 → 폼 JS 분기용
|
||||||
|
model.addAttribute("twofaRequired", isAppModifyTwofaRequired());
|
||||||
|
|
||||||
return new ModelAndView(API_KEY_MODIFY_STEP2);
|
return new ModelAndView(API_KEY_MODIFY_STEP2);
|
||||||
}
|
}
|
||||||
@@ -927,6 +934,7 @@ public class MyAppController {
|
|||||||
@RequestParam(value = "selectedApis", required = false) List<String> selectedApis,
|
@RequestParam(value = "selectedApis", required = false) List<String> selectedApis,
|
||||||
@ModelAttribute("apiKeyModification") ApiKeyRegistrationDTO modification,
|
@ModelAttribute("apiKeyModification") ApiKeyRegistrationDTO modification,
|
||||||
SessionStatus sessionStatus,
|
SessionStatus sessionStatus,
|
||||||
|
HttpSession session,
|
||||||
RedirectAttributes redirectAttributes) {
|
RedirectAttributes redirectAttributes) {
|
||||||
|
|
||||||
// 1단계가 완료되었는지 검증
|
// 1단계가 완료되었는지 검증
|
||||||
@@ -950,6 +958,14 @@ public class MyAppController {
|
|||||||
return new ModelAndView("redirect:/myapikey/modify/step1?clientId=" + modification.getClientId());
|
return new ModelAndView("redirect:/myapikey/modify/step1?clientId=" + modification.getClientId());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 반영 직전 2FA: 통과권이 없으면 커밋하지 않고 step2 로 되돌린다(프론트가 먼저 2FA 팝업을 띄운다).
|
||||||
|
// 진입(step1)이 아닌 최종 반영 시점에만 인증을 요구해 다단계 진행 중 중복 인증을 막는다.
|
||||||
|
if (isAppModifyTwofaRequired()
|
||||||
|
&& !twoFactorService.consumeStepUpPass(session, StepUpProtectedPaths.APP_MODIFY_COMMIT)) {
|
||||||
|
redirectAttributes.addFlashAttribute("error", "추가 인증(2FA) 후 다시 시도해 주세요.");
|
||||||
|
return new ModelAndView("redirect:/myapikey/modify/step2");
|
||||||
|
}
|
||||||
|
|
||||||
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
|
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -1021,6 +1037,12 @@ public class MyAppController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 앱 수정 최종 반영 직전 2FA(step-up)가 현재 활성인지 — 전체/지점 스위치 AND */
|
||||||
|
private boolean isAppModifyTwofaRequired() {
|
||||||
|
return twoFactorProperties.isStepUpEnabled()
|
||||||
|
&& twoFactorProperties.isStepUpPointEnabled(StepUpProtectedPaths.APP_MODIFY_COMMIT);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-6
@@ -11,7 +11,8 @@ import java.util.Set;
|
|||||||
* <p>검증 레벨(완화 정책)</p>
|
* <p>검증 레벨(완화 정책)</p>
|
||||||
* <ul>
|
* <ul>
|
||||||
* <li>{@link Level#TWO_FACTOR} — 공통 2FA 팝업(휴대폰/이메일 인증번호). 진입 인터셉터 또는
|
* <li>{@link Level#TWO_FACTOR} — 공통 2FA 팝업(휴대폰/이메일 인증번호). 진입 인터셉터 또는
|
||||||
* AJAX 401 신호로 유도. 예: Secret 조회/앱 해지/앱 정보수정.</li>
|
* AJAX 401 신호로 유도. 예: Secret 조회/앱 해지. 앱 정보수정은 최종 반영(commit)
|
||||||
|
* 직전에 컨트롤러가 통과권을 요구한다(다단계 진행 중 중복 인증 방지).</li>
|
||||||
* <li>{@link Level#PASSWORD} — 현재 비밀번호 재확인만 요구(2FA 없음). 별도 확인 페이지
|
* <li>{@link Level#PASSWORD} — 현재 비밀번호 재확인만 요구(2FA 없음). 별도 확인 페이지
|
||||||
* ({@code /auth/stepup/password})로 유도. 예: 내 정보 변경({@code /mypage}).</li>
|
* ({@code /auth/stepup/password})로 유도. 예: 내 정보 변경({@code /mypage}).</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
@@ -43,12 +44,14 @@ public final class StepUpProtectedPaths {
|
|||||||
public static final String REVEAL_SECRET = "/myapikey/credential/reveal-secret";
|
public static final String REVEAL_SECRET = "/myapikey/credential/reveal-secret";
|
||||||
/** 앱 해지 신청 (AJAX POST) */
|
/** 앱 해지 신청 (AJAX POST) */
|
||||||
public static final String APP_KEY_DELETE = "/myapikey/api_key_delete";
|
public static final String APP_KEY_DELETE = "/myapikey/api_key_delete";
|
||||||
/** 앱 정보 수정 페이지 진입 (GET) */
|
/** 앱 정보 수정 최종 반영(commit, POST /modify/step2) — 반영 직전 2FA. 진입/중간 단계는 가드하지 않음 */
|
||||||
public static final String APP_MODIFY_STEP1 = "/myapikey/modify/step1";
|
public static final String APP_MODIFY_COMMIT = "/myapikey/modify/step2";
|
||||||
/** 개인정보 변경 페이지 진입 (GET, 정확 일치) — PASSWORD 레벨 */
|
/** 개인정보 변경 페이지 진입 (GET, 정확 일치) — PASSWORD 레벨 */
|
||||||
public static final String MYPAGE = "/mypage";
|
public static final String MYPAGE = "/mypage";
|
||||||
/** 비밀번호 변경 반영(commit, POST) — 반영 직전 2FA. 진입(GET)은 가드하지 않음 */
|
/** 비밀번호 변경 반영(commit, POST) — 반영 직전 2FA. 진입(GET)은 가드하지 않음 */
|
||||||
public static final String PASSWORD_CHANGE = "/password/change";
|
public static final String PASSWORD_CHANGE = "/password/change";
|
||||||
|
/** 회원 탈퇴 반영(commit, POST) — 반영 직전 2FA. 팝업(사유 입력) 후 프론트가 2FA 를 띄운다 */
|
||||||
|
public static final String WITHDRAW = "/withdraw";
|
||||||
|
|
||||||
/** PTL_PROPERTY 지점 키 접두 (전체 스위치 two-factor.stepup.enabled 와 구분) */
|
/** PTL_PROPERTY 지점 키 접두 (전체 스위치 two-factor.stepup.enabled 와 구분) */
|
||||||
private static final String KEY_PREFIX = "two-factor.stepup.";
|
private static final String KEY_PREFIX = "two-factor.stepup.";
|
||||||
@@ -63,26 +66,31 @@ public final class StepUpProtectedPaths {
|
|||||||
static {
|
static {
|
||||||
Map<String, String> keys = new LinkedHashMap<>();
|
Map<String, String> keys = new LinkedHashMap<>();
|
||||||
keys.put(REVEAL_SECRET, KEY_PREFIX + "reveal-secret");
|
keys.put(REVEAL_SECRET, KEY_PREFIX + "reveal-secret");
|
||||||
keys.put(APP_MODIFY_STEP1, KEY_PREFIX + "app-modify");
|
keys.put(APP_MODIFY_COMMIT, KEY_PREFIX + "app-modify");
|
||||||
keys.put(APP_KEY_DELETE, KEY_PREFIX + "app-delete");
|
keys.put(APP_KEY_DELETE, KEY_PREFIX + "app-delete");
|
||||||
keys.put(MYPAGE, KEY_PREFIX + "mypage");
|
keys.put(MYPAGE, KEY_PREFIX + "mypage");
|
||||||
keys.put(PASSWORD_CHANGE, KEY_PREFIX + "password-change");
|
keys.put(PASSWORD_CHANGE, KEY_PREFIX + "password-change");
|
||||||
|
keys.put(WITHDRAW, KEY_PREFIX + "withdraw");
|
||||||
PATH_TO_KEY = Collections.unmodifiableMap(keys);
|
PATH_TO_KEY = Collections.unmodifiableMap(keys);
|
||||||
|
|
||||||
Map<String, Level> levels = new LinkedHashMap<>();
|
Map<String, Level> levels = new LinkedHashMap<>();
|
||||||
levels.put(REVEAL_SECRET, Level.TWO_FACTOR);
|
levels.put(REVEAL_SECRET, Level.TWO_FACTOR);
|
||||||
levels.put(APP_MODIFY_STEP1, Level.TWO_FACTOR);
|
levels.put(APP_MODIFY_COMMIT, Level.TWO_FACTOR);
|
||||||
levels.put(APP_KEY_DELETE, Level.TWO_FACTOR);
|
levels.put(APP_KEY_DELETE, Level.TWO_FACTOR);
|
||||||
levels.put(MYPAGE, Level.PASSWORD);
|
levels.put(MYPAGE, Level.PASSWORD);
|
||||||
levels.put(PASSWORD_CHANGE, Level.TWO_FACTOR);
|
levels.put(PASSWORD_CHANGE, Level.TWO_FACTOR);
|
||||||
|
levels.put(WITHDRAW, Level.TWO_FACTOR);
|
||||||
PATH_TO_LEVEL = Collections.unmodifiableMap(levels);
|
PATH_TO_LEVEL = Collections.unmodifiableMap(levels);
|
||||||
|
|
||||||
// 인터셉터 진입 자동 차단: 2FA 레벨 중 "진입 시점" 보호가 필요한 경로만.
|
// 인터셉터 진입 자동 차단: 2FA 레벨 중 "진입 시점" 보호가 필요한 경로만.
|
||||||
// - PASSWORD_CHANGE 는 반영(POST commit) 직전에 컨트롤러가 통과권을 요구 → 제외
|
// - PASSWORD_CHANGE 는 반영(POST commit) 직전에 컨트롤러가 통과권을 요구 → 제외
|
||||||
|
// - APP_MODIFY_COMMIT 도 동일 — 다단계(step1→step2) 진행 중 중복 인증을 막기 위해
|
||||||
|
// 최종 반영 직전에만 컨트롤러가 통과권을 요구 → 제외
|
||||||
// - MYPAGE 는 별도 확인 페이지로 컨트롤러가 유도(PASSWORD 레벨) → 제외
|
// - MYPAGE 는 별도 확인 페이지로 컨트롤러가 유도(PASSWORD 레벨) → 제외
|
||||||
|
// - WITHDRAW 는 팝업(사유 입력)→2FA→제출 순서로 프론트가 유도하고
|
||||||
|
// 컨트롤러가 커밋 직전 통과권을 요구 → 제외
|
||||||
Set<String> guarded = new java.util.LinkedHashSet<>();
|
Set<String> guarded = new java.util.LinkedHashSet<>();
|
||||||
guarded.add(REVEAL_SECRET);
|
guarded.add(REVEAL_SECRET);
|
||||||
guarded.add(APP_MODIFY_STEP1);
|
|
||||||
guarded.add(APP_KEY_DELETE);
|
guarded.add(APP_KEY_DELETE);
|
||||||
INTERCEPTOR_GUARDED = Collections.unmodifiableSet(guarded);
|
INTERCEPTOR_GUARDED = Collections.unmodifiableSet(guarded);
|
||||||
}
|
}
|
||||||
|
|||||||
+51
@@ -0,0 +1,51 @@
|
|||||||
|
package com.eactive.apim.portal.apps.login.controller;
|
||||||
|
|
||||||
|
import com.eactive.apim.portal.apps.login.service.DuplicateLoginService;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import javax.servlet.http.HttpServletRequest;
|
||||||
|
import javax.servlet.http.HttpSession;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 동시 접속(중복 세션) 확인 대기 상태(로그인 2FA off 경로)의 확정/취소 API.
|
||||||
|
*
|
||||||
|
* <p>대기 상태는 1차 인증(ID/PW) 성공 후 SuccessHandler 만 세팅하므로, 이 엔드포인트는
|
||||||
|
* 비밀번호 검증을 통과한 세션에서만 의미가 있다. 모든 POST 는 세션 기반
|
||||||
|
* CSRF(X-XSRF-TOKEN) 보호를 받는다.</p>
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/login/duplicate")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class DuplicateLoginController {
|
||||||
|
|
||||||
|
private final DuplicateLoginService duplicateLoginService;
|
||||||
|
|
||||||
|
/** 기존 접속 해제 확인 → 로그인 확정. 무효(만료/상태 변경) 시 재로그인 안내 */
|
||||||
|
@PostMapping("/confirm")
|
||||||
|
public Map<String, Object> confirm(HttpServletRequest request, HttpSession session) {
|
||||||
|
Map<String, Object> result = new HashMap<>();
|
||||||
|
String redirect = duplicateLoginService.confirm(request, session);
|
||||||
|
if (redirect != null) {
|
||||||
|
result.put("valid", true);
|
||||||
|
result.put("redirect", redirect);
|
||||||
|
} else {
|
||||||
|
result.put("valid", false);
|
||||||
|
result.put("message", "로그인 확인이 만료되었습니다. 다시 로그인해주세요.");
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 확인 취소 — 로그인 포기(익명 유지) */
|
||||||
|
@PostMapping("/cancel")
|
||||||
|
public Map<String, Object> cancel(HttpSession session) {
|
||||||
|
duplicateLoginService.cancel(session);
|
||||||
|
Map<String, Object> result = new HashMap<>();
|
||||||
|
result.put("valid", true);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.eactive.apim.portal.apps.login.controller;
|
package com.eactive.apim.portal.apps.login.controller;
|
||||||
|
|
||||||
import com.eactive.apim.portal.apps.auth.twofactor.TwoFactorService;
|
import com.eactive.apim.portal.apps.auth.twofactor.TwoFactorService;
|
||||||
|
import com.eactive.apim.portal.apps.login.service.DuplicateLoginService;
|
||||||
import com.eactive.apim.portal.common.exception.PortalRedirectException;
|
import com.eactive.apim.portal.common.exception.PortalRedirectException;
|
||||||
import com.eactive.apim.portal.common.pagerouter.PageHandler;
|
import com.eactive.apim.portal.common.pagerouter.PageHandler;
|
||||||
import org.apache.commons.lang3.StringUtils;
|
import org.apache.commons.lang3.StringUtils;
|
||||||
@@ -24,9 +25,11 @@ import static com.eactive.apim.portal.apps.login.constants.LoginConstants.LOGIN_
|
|||||||
public class LoginHandler implements PageHandler {
|
public class LoginHandler implements PageHandler {
|
||||||
|
|
||||||
private final TwoFactorService twoFactorService;
|
private final TwoFactorService twoFactorService;
|
||||||
|
private final DuplicateLoginService duplicateLoginService;
|
||||||
|
|
||||||
public LoginHandler(TwoFactorService twoFactorService) {
|
public LoginHandler(TwoFactorService twoFactorService, DuplicateLoginService duplicateLoginService) {
|
||||||
this.twoFactorService = twoFactorService;
|
this.twoFactorService = twoFactorService;
|
||||||
|
this.duplicateLoginService = duplicateLoginService;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -55,7 +58,24 @@ public class LoginHandler implements PageHandler {
|
|||||||
|
|
||||||
// 로그인 2FA 대기 상태면(1차 인증 통과 후) 추가 인증 팝업 자동 오픈 플래그를 내려준다.
|
// 로그인 2FA 대기 상태면(1차 인증 통과 후) 추가 인증 팝업 자동 오픈 플래그를 내려준다.
|
||||||
// pending 중에는 아직 익명이므로 아래 인증자 리다이렉트에 걸리지 않는다.
|
// pending 중에는 아직 익명이므로 아래 인증자 리다이렉트에 걸리지 않는다.
|
||||||
model.addAttribute("twoFactorPending", twoFactorService.hasPendingLogin(session));
|
boolean twoFactorPending = twoFactorService.hasPendingLogin(session);
|
||||||
|
model.addAttribute("twoFactorPending", twoFactorPending);
|
||||||
|
|
||||||
|
// 동시 접속 안내 — 반드시 1차 인증 통과 후(2FA pending 또는 중복 확인 대기)에만 노출한다.
|
||||||
|
// - 2FA on: 확인 후 2FA 팝업 진행(취소 시 /auth/2fa/cancel)
|
||||||
|
// - 2FA off: 확인 후 /login/duplicate/confirm 으로 확정
|
||||||
|
String pendingLoginId = null;
|
||||||
|
boolean duplicateConfirmPending = false;
|
||||||
|
if (twoFactorPending) {
|
||||||
|
pendingLoginId = (String) session.getAttribute(TwoFactorService.ATTR_PENDING_LOGIN_ID);
|
||||||
|
} else if (duplicateLoginService.hasPending(session)
|
||||||
|
&& "1".equals(httpRequest.getParameter("duplicate"))) {
|
||||||
|
pendingLoginId = duplicateLoginService.pendingLoginId(session);
|
||||||
|
duplicateConfirmPending = true;
|
||||||
|
}
|
||||||
|
model.addAttribute("duplicateConfirmPending", duplicateConfirmPending);
|
||||||
|
model.addAttribute("duplicateInfo",
|
||||||
|
pendingLoginId != null ? duplicateLoginService.activeSessionInfo(pendingLoginId) : null);
|
||||||
|
|
||||||
// 이미 인증된 사용자인지 확인
|
// 이미 인증된 사용자인지 확인
|
||||||
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
package com.eactive.apim.portal.apps.login.service;
|
||||||
|
|
||||||
|
import com.eactive.apim.portal.apps.login.constants.LoginType;
|
||||||
|
import com.eactive.apim.portal.apps.session.entity.UserSession;
|
||||||
|
import com.eactive.apim.portal.apps.session.service.UserSessionService;
|
||||||
|
import com.eactive.apim.portal.apps.user.service.PortalUserAuthService;
|
||||||
|
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
||||||
|
import com.eactive.apim.portal.portalorg.entity.PortalOrgEnums;
|
||||||
|
import com.eactive.apim.portal.portaluser.entity.PortalUser;
|
||||||
|
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
|
||||||
|
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import javax.servlet.http.HttpServletRequest;
|
||||||
|
import javax.servlet.http.HttpSession;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 로그인 시 동시 접속(중복 세션) 확인 처리.
|
||||||
|
*
|
||||||
|
* <p>중복 확인은 반드시 <b>1차 인증(ID/PW) 성공 후</b>에만 수행한다. 비밀번호 검증 전에
|
||||||
|
* 노출하면 임의 계정의 접속 여부·IP 가 인증 없이 조회되는 정보 노출이 된다(기존
|
||||||
|
* {@code /api/session/check-duplicate} 사전 체크 방식의 문제).</p>
|
||||||
|
*
|
||||||
|
* <p>두 경로에서 쓰인다:
|
||||||
|
* <ul>
|
||||||
|
* <li>로그인 2FA on — 2FA pending 상태의 로그인 페이지가 {@link #activeSessionInfo(String)}
|
||||||
|
* 로 안내 정보를 내려주고, 확인 후 2FA 팝업으로 진행(취소 시 {@code /auth/2fa/cancel}).</li>
|
||||||
|
* <li>로그인 2FA off — SuccessHandler 가 확정을 보류하고 {@link #begin} 으로 대기 상태 전환.
|
||||||
|
* 사용자가 확인하면 {@link #confirm} 이 인증을 확정한다(기존 세션은
|
||||||
|
* {@link LoginFinalizer#finalizeLogin} 의 forceLogoutOtherSessions 로 해제).</li>
|
||||||
|
* </ul></p>
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@Transactional
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class DuplicateLoginService {
|
||||||
|
|
||||||
|
/** 동시 접속 확인 대기 - 대상 사용자 id (2FA off 경로) */
|
||||||
|
public static final String ATTR_PENDING_USER_ID = "DUP_PENDING_USER_ID";
|
||||||
|
/** 동시 접속 확인 대기 - loginId */
|
||||||
|
public static final String ATTR_PENDING_LOGIN_ID = "DUP_PENDING_LOGIN_ID";
|
||||||
|
/** 동시 접속 확인 대기 - 진입 시각 */
|
||||||
|
public static final String ATTR_PENDING_AT = "DUP_PENDING_AT";
|
||||||
|
|
||||||
|
/** 확인 대기 유효시간(초). 초과 시 처음부터 재로그인 */
|
||||||
|
public static final int PENDING_TTL_SECONDS = 120;
|
||||||
|
|
||||||
|
private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||||
|
|
||||||
|
private final UserSessionService userSessionService;
|
||||||
|
private final PortalUserRepository portalUserRepository;
|
||||||
|
private final PortalUserAuthService portalUserAuthService;
|
||||||
|
private final LoginFinalizer loginFinalizer;
|
||||||
|
|
||||||
|
/** 해당 계정의 활성 세션(다른 곳 접속) 존재 여부 */
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public boolean hasActiveSession(String loginId) {
|
||||||
|
return activeSession(loginId).isPresent();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 활성 세션 안내 정보(마스킹 IP·접속 시각). 없으면 null.
|
||||||
|
* 로그인 페이지 확인 팝업 표시용 — 1차 인증 통과 후에만 호출해야 한다.
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public Map<String, String> activeSessionInfo(String loginId) {
|
||||||
|
Optional<UserSession> active = activeSession(loginId);
|
||||||
|
if (!active.isPresent()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
Map<String, String> info = new HashMap<>();
|
||||||
|
info.put("ipAddress", maskIpAddress(active.get().getIpAddress()));
|
||||||
|
info.put("loginTime", active.get().getLoginTime().format(TIME_FORMATTER));
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// 2FA off 경로: 확정 보류 → 확인 → 확정
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/** 1차 인증 성공 사용자를 동시 접속 확인 대기 상태로 세팅한다. (SecurityContext 클리어는 호출부 책임) */
|
||||||
|
public void begin(HttpSession session, PortalUser user) {
|
||||||
|
session.setAttribute(ATTR_PENDING_USER_ID, user.getId());
|
||||||
|
session.setAttribute(ATTR_PENDING_LOGIN_ID, user.getLoginId());
|
||||||
|
session.setAttribute(ATTR_PENDING_AT, LocalDateTime.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean hasPending(HttpSession session) {
|
||||||
|
return session != null && session.getAttribute(ATTR_PENDING_USER_ID) != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String pendingLoginId(HttpSession session) {
|
||||||
|
return session == null ? null : (String) session.getAttribute(ATTR_PENDING_LOGIN_ID);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 동시 접속 확인 후 로그인 확정. 대기 상태가 유효하면 인증을 세팅하고 최종 이동 URL 을
|
||||||
|
* 반환한다(기존 세션 해제 포함). 무효(만료/상태 변경)면 null — 재로그인 필요.
|
||||||
|
*/
|
||||||
|
public String confirm(HttpServletRequest request, HttpSession session) {
|
||||||
|
String userId = (String) session.getAttribute(ATTR_PENDING_USER_ID);
|
||||||
|
String loginId = (String) session.getAttribute(ATTR_PENDING_LOGIN_ID);
|
||||||
|
Object at = session.getAttribute(ATTR_PENDING_AT);
|
||||||
|
cancel(session); // 1회용 — 성공/실패 무관하게 대기 상태는 소멸
|
||||||
|
|
||||||
|
if (userId == null || !(at instanceof LocalDateTime)
|
||||||
|
|| ((LocalDateTime) at).plusSeconds(PENDING_TTL_SECONDS).isBefore(LocalDateTime.now())) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
PortalUser user = portalUserRepository.findById(userId).orElse(null);
|
||||||
|
if (user == null || !isLoginStillAllowed(user)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 프로그래매틱 인증 확정 (요청 종료 시 SecurityContextPersistenceFilter 가 세션에 저장)
|
||||||
|
PortalAuthenticatedUser authUser = portalUserAuthService.buildAuthenticatedUser(user);
|
||||||
|
UsernamePasswordAuthenticationToken token =
|
||||||
|
new UsernamePasswordAuthenticationToken(authUser, null, authUser.getAuthorities());
|
||||||
|
token.setDetails(authUser);
|
||||||
|
SecurityContextHolder.getContext().setAuthentication(token);
|
||||||
|
|
||||||
|
return loginFinalizer.finalizeLogin(user, loginId, request, LoginType.NORMAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 확인 취소 — 대기 상태 정리(익명 유지) */
|
||||||
|
public void cancel(HttpSession session) {
|
||||||
|
session.removeAttribute(ATTR_PENDING_USER_ID);
|
||||||
|
session.removeAttribute(ATTR_PENDING_LOGIN_ID);
|
||||||
|
session.removeAttribute(ATTR_PENDING_AT);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// 내부 helper
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
private Optional<UserSession> activeSession(String loginId) {
|
||||||
|
if (loginId == null) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
return userSessionService.getActiveSession(loginId.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 1차 인증~확인 사이 계정 상태 변경 방어 (TwoFactorService.revalidateLoginState 와 동일 기준) */
|
||||||
|
private boolean isLoginStillAllowed(PortalUser user) {
|
||||||
|
if ("Y".equalsIgnoreCase(user.getAccountLockYn())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (PortalUserEnums.UserStatus.ADMINBLOCK.equals(user.getUserStatus())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (PortalUserEnums.ApprovalStatus.PENDING.equals(user.getApprovalStatus())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return user.getPortalOrg() == null
|
||||||
|
|| PortalOrgEnums.ApprovalStatus.COMPLETED.equals(user.getPortalOrg().getApprovalStatus());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IP 주소 마스킹 (3번째 옥텟을 ***로 치환). 예: 192.168.240.178 → 192.168.***.178
|
||||||
|
*/
|
||||||
|
private static String maskIpAddress(String ip) {
|
||||||
|
if (ip == null || ip.isEmpty()) {
|
||||||
|
return "알 수 없음";
|
||||||
|
}
|
||||||
|
String[] parts = ip.split("\\.");
|
||||||
|
if (parts.length == 4) {
|
||||||
|
return parts[0] + "." + parts[1] + ".***." + parts[3];
|
||||||
|
}
|
||||||
|
// IPv6 등 다른 형식은 일부만 표시
|
||||||
|
if (ip.length() > 8) {
|
||||||
|
return ip.substring(0, 4) + "****" + ip.substring(ip.length() - 4);
|
||||||
|
}
|
||||||
|
return "***";
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
-49
@@ -1,6 +1,5 @@
|
|||||||
package com.eactive.apim.portal.apps.session.controller;
|
package com.eactive.apim.portal.apps.session.controller;
|
||||||
|
|
||||||
import com.eactive.apim.portal.apps.session.entity.UserSession;
|
|
||||||
import com.eactive.apim.portal.apps.session.service.UserSessionService;
|
import com.eactive.apim.portal.apps.session.service.UserSessionService;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
@@ -9,23 +8,23 @@ import org.springframework.security.web.csrf.CsrfToken;
|
|||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestParam;
|
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
import javax.servlet.http.HttpServletRequest;
|
import javax.servlet.http.HttpServletRequest;
|
||||||
import javax.servlet.http.HttpSession;
|
import javax.servlet.http.HttpSession;
|
||||||
import java.time.format.DateTimeFormatter;
|
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 세션 타이머/유휴 로그아웃/중복로그인 처리용 REST API.
|
* 세션 타이머/유휴 로그아웃 처리용 REST API.
|
||||||
|
*
|
||||||
|
* <p>중복 세션 확인은 비밀번호 검증 전 정보 노출 문제로 로그인 전 사전 체크
|
||||||
|
* ({@code /api/session/check-duplicate})를 제거하고, 1차 인증 통과 후
|
||||||
|
* {@code DuplicateLoginService} 가 처리한다.</p>
|
||||||
*
|
*
|
||||||
* <ul>
|
* <ul>
|
||||||
* <li>GET /api/session/status - 잔여 시간/유효성 폴링 (인증 필요)</li>
|
* <li>GET /api/session/status - 잔여 시간/유효성 폴링 (인증 필요)</li>
|
||||||
* <li>POST /api/session/heartbeat - 세션 연장 (lastAccessTime 갱신)</li>
|
* <li>POST /api/session/heartbeat - 세션 연장 (lastAccessTime 갱신)</li>
|
||||||
* <li>POST /api/session/check-duplicate - 로그인 전 중복 세션 확인 (CSRF 예외)</li>
|
|
||||||
* <li>GET /api/session/ping - 익명 세션 keepalive (로그인/회원가입 페이지)</li>
|
* <li>GET /api/session/ping - 익명 세션 keepalive (로그인/회원가입 페이지)</li>
|
||||||
* <li>GET /api/session/csrf - 현재 CSRF 토큰 조회 (로그인 제출 직전 안전망)</li>
|
* <li>GET /api/session/csrf - 현재 CSRF 토큰 조회 (로그인 제출 직전 안전망)</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
@@ -36,32 +35,8 @@ import java.util.Optional;
|
|||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class SessionApiController {
|
public class SessionApiController {
|
||||||
|
|
||||||
private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
|
||||||
|
|
||||||
private final UserSessionService userSessionService;
|
private final UserSessionService userSessionService;
|
||||||
|
|
||||||
/**
|
|
||||||
* 로그인 전 중복 세션 확인
|
|
||||||
*/
|
|
||||||
@PostMapping("/check-duplicate")
|
|
||||||
public ResponseEntity<Map<String, Object>> checkDuplicate(@RequestParam("loginId") String loginId) {
|
|
||||||
Map<String, Object> result = new HashMap<>();
|
|
||||||
String normalizedLoginId = loginId != null ? loginId.toLowerCase() : "";
|
|
||||||
|
|
||||||
Optional<UserSession> activeSession = userSessionService.getActiveSession(normalizedLoginId);
|
|
||||||
|
|
||||||
if (activeSession.isPresent()) {
|
|
||||||
UserSession session = activeSession.get();
|
|
||||||
result.put("duplicateSession", true);
|
|
||||||
result.put("ipAddress", maskIpAddress(session.getIpAddress()));
|
|
||||||
result.put("loginTime", session.getLoginTime().format(TIME_FORMATTER));
|
|
||||||
} else {
|
|
||||||
result.put("duplicateSession", false);
|
|
||||||
}
|
|
||||||
|
|
||||||
return ResponseEntity.ok(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 세션 상태 폴링 (인증 필요)
|
* 세션 상태 폴링 (인증 필요)
|
||||||
*/
|
*/
|
||||||
@@ -142,23 +117,4 @@ public class SessionApiController {
|
|||||||
}
|
}
|
||||||
return ResponseEntity.ok(result);
|
return ResponseEntity.ok(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* IP 주소 마스킹 (3번째 옥텟을 ***로 치환)
|
|
||||||
* 예: 192.168.240.178 → 192.168.***.178
|
|
||||||
*/
|
|
||||||
private String maskIpAddress(String ip) {
|
|
||||||
if (ip == null || ip.isEmpty()) {
|
|
||||||
return "알 수 없음";
|
|
||||||
}
|
|
||||||
String[] parts = ip.split("\\.");
|
|
||||||
if (parts.length == 4) {
|
|
||||||
return parts[0] + "." + parts[1] + ".***." + parts[3];
|
|
||||||
}
|
|
||||||
// IPv6 등 다른 형식은 일부만 표시
|
|
||||||
if (ip.length() > 8) {
|
|
||||||
return ip.substring(0, 4) + "****" + ip.substring(ip.length() - 4);
|
|
||||||
}
|
|
||||||
return "***";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -136,7 +136,8 @@ public class AccountController {
|
|||||||
new SecurityContextLogoutHandler().logout(request, response,
|
new SecurityContextLogoutHandler().logout(request, response,
|
||||||
SecurityContextHolder.getContext().getAuthentication());
|
SecurityContextHolder.getContext().getAuthentication());
|
||||||
|
|
||||||
redirectAttributes.addFlashAttribute("success", "비밀번호가 성공적으로 변경되었습니다.");
|
redirectAttributes.addFlashAttribute("success",
|
||||||
|
"비밀번호가 성공적으로 변경되었습니다.<br>새 비밀번호로 다시 로그인해주세요.");
|
||||||
return "redirect:/login";
|
return "redirect:/login";
|
||||||
} catch (IllegalArgumentException e) {
|
} catch (IllegalArgumentException e) {
|
||||||
// 검증 실패(비밀번호 규칙/이력 등) — 사용자에게 안내, 스택은 불필요
|
// 검증 실패(비밀번호 규칙/이력 등) — 사용자에게 안내, 스택은 불필요
|
||||||
@@ -182,6 +183,9 @@ public class AccountController {
|
|||||||
// 기존 user 객체도 유지 (다른 곳에서 필요할 수 있으므로)
|
// 기존 user 객체도 유지 (다른 곳에서 필요할 수 있으므로)
|
||||||
mav.addObject("user", user);
|
mav.addObject("user", user);
|
||||||
|
|
||||||
|
// 회원 탈퇴 팝업: 제출 전에 2FA 팝업을 띄울지 여부
|
||||||
|
mav.addObject("withdrawTwofaRequired", isWithdrawTwofaRequired());
|
||||||
|
|
||||||
// ROLE_USER인 경우 초대 여부 확인
|
// ROLE_USER인 경우 초대 여부 확인
|
||||||
if (currentUser.getRoleCode() == RoleCode.ROLE_USER) {
|
if (currentUser.getRoleCode() == RoleCode.ROLE_USER) {
|
||||||
java.util.Optional<UserInvitation> pendingInvitation =
|
java.util.Optional<UserInvitation> pendingInvitation =
|
||||||
@@ -401,21 +405,35 @@ public class AccountController {
|
|||||||
|
|
||||||
@PostMapping("/withdraw")
|
@PostMapping("/withdraw")
|
||||||
public String processWithdrawal(
|
public String processWithdrawal(
|
||||||
|
@RequestParam(value = "withdrawReason", required = false) String withdrawReason,
|
||||||
HttpSession session,
|
HttpSession session,
|
||||||
RedirectAttributes redirectAttributes) {
|
RedirectAttributes redirectAttributes) {
|
||||||
try {
|
try {
|
||||||
|
// 탈퇴 사유 필수
|
||||||
|
if (withdrawReason == null || withdrawReason.trim().isEmpty()) {
|
||||||
|
redirectAttributes.addFlashAttribute("error", "탈퇴 사유를 입력해 주세요.");
|
||||||
|
return "redirect:/mypage";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 반영 직전 2FA: 통과권이 없으면 커밋하지 않는다(프론트가 먼저 2FA 팝업을 띄운다).
|
||||||
|
if (isWithdrawTwofaRequired()
|
||||||
|
&& !twoFactorService.consumeStepUpPass(session, StepUpProtectedPaths.WITHDRAW)) {
|
||||||
|
redirectAttributes.addFlashAttribute("error", "추가 인증(2FA) 후 다시 시도해 주세요.");
|
||||||
|
return "redirect:/mypage";
|
||||||
|
}
|
||||||
|
|
||||||
// 현재 로그인한 사용자 정보 가져오기
|
// 현재 로그인한 사용자 정보 가져오기
|
||||||
PortalAuthenticatedUser currentUser = SecurityUtil.getPortalAuthenticatedUser();
|
PortalAuthenticatedUser currentUser = SecurityUtil.getPortalAuthenticatedUser();
|
||||||
|
|
||||||
// 회원 탈퇴 처리
|
// 회원 탈퇴 처리
|
||||||
if (currentUser != null) {
|
if (currentUser != null) {
|
||||||
userFacade.withdrawUser(currentUser.getId());
|
userFacade.withdrawUser(currentUser.getId(), withdrawReason.trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
session.invalidate();
|
session.invalidate();
|
||||||
SecurityContextHolder.clearContext();
|
SecurityContextHolder.clearContext();
|
||||||
|
|
||||||
redirectAttributes.addFlashAttribute("success", "회원 탈퇴 신청이 완료 되었습니다. API Portal 회원 정보가 완전히 삭제 됩니다.");
|
redirectAttributes.addFlashAttribute("success", "회원 탈퇴가 완료 되었습니다. API Portal 회원 정보가 완전히 삭제 되었습니다.");
|
||||||
return "redirect:/";
|
return "redirect:/";
|
||||||
} catch (IllegalArgumentException e) {
|
} catch (IllegalArgumentException e) {
|
||||||
redirectAttributes.addFlashAttribute("error", e.getMessage());
|
redirectAttributes.addFlashAttribute("error", e.getMessage());
|
||||||
@@ -423,6 +441,12 @@ public class AccountController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 회원 탈퇴({@code /withdraw}) 반영 직전 2FA 를 요구할지 여부 */
|
||||||
|
private boolean isWithdrawTwofaRequired() {
|
||||||
|
return twoFactorProperties.isStepUpEnabled()
|
||||||
|
&& twoFactorProperties.isStepUpPointEnabled(StepUpProtectedPaths.WITHDRAW);
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/mypage/verification-email")
|
@GetMapping("/mypage/verification-email")
|
||||||
public String showVerificationEmailPage(Model model) {
|
public String showVerificationEmailPage(Model model) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ public interface UserFacade {
|
|||||||
|
|
||||||
void updateCorporateManager(PortalUserDTO portalUserDTO);
|
void updateCorporateManager(PortalUserDTO portalUserDTO);
|
||||||
|
|
||||||
void withdrawUser(String userId);
|
void withdrawUser(String userId, String withdrawalReason);
|
||||||
|
|
||||||
void activateUserByEmail(String email);
|
void activateUserByEmail(String email);
|
||||||
}
|
}
|
||||||
@@ -131,7 +131,7 @@ public class UserFacadeImpl implements UserFacade {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void withdrawUser(String userId) {
|
public void withdrawUser(String userId, String withdrawalReason) {
|
||||||
PortalUser user = portalUserService.findById(userId);
|
PortalUser user = portalUserService.findById(userId);
|
||||||
|
|
||||||
// 법인 관리자 탈퇴 제한
|
// 법인 관리자 탈퇴 제한
|
||||||
@@ -147,7 +147,7 @@ public class UserFacadeImpl implements UserFacade {
|
|||||||
// 메시지 요청정보 삭제
|
// 메시지 요청정보 삭제
|
||||||
messageRequestFacade.deleteUserMessage(user.getUserName(),user.getLoginId());
|
messageRequestFacade.deleteUserMessage(user.getUserName(),user.getLoginId());
|
||||||
|
|
||||||
portalUserService.deleteUser(user);
|
portalUserService.deleteUser(user, withdrawalReason);
|
||||||
log.info("회원 탈퇴 처리 완료: {}", StringMaskingUtil.maskLoginId(user.getLoginId()));
|
log.info("회원 탈퇴 처리 완료: {}", StringMaskingUtil.maskLoginId(user.getLoginId()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import com.eactive.apim.portal.common.exception.UserNotFoundException;
|
|||||||
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
||||||
import com.eactive.apim.portal.common.util.EncryptionUtil;
|
import com.eactive.apim.portal.common.util.EncryptionUtil;
|
||||||
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.config.PortalProperties;
|
import com.eactive.apim.portal.config.PortalProperties;
|
||||||
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;
|
||||||
@@ -136,12 +135,9 @@ public class PortalUserAuthService implements UserDetailsService {
|
|||||||
throw new UserNotFoundException("입력하신 사용자 정보가 올바르지 않습니다. 다시 확인해 주세요.");
|
throw new UserNotFoundException("입력하신 사용자 정보가 올바르지 않습니다. 다시 확인해 주세요.");
|
||||||
}
|
}
|
||||||
|
|
||||||
return users.stream().map(user -> {
|
// 아이디 찾기: 이름+휴대폰 본인인증을 마친 사용자에게 보여주는 결과이므로
|
||||||
PortalUserDTO dto = portalUserMapper.toDTO(user);
|
// 이메일(아이디)을 마스킹 없이 그대로 반환한다.
|
||||||
dto.setMaskedEmailAddr(StringMaskingUtil.maskEmail(dto.getLoginId()));
|
return users.stream().map(portalUserMapper::toDTO).collect(Collectors.toList());
|
||||||
dto.setLoginId(null);
|
|
||||||
return dto;
|
|
||||||
}).collect(Collectors.toList());
|
|
||||||
|
|
||||||
} catch (UserNotFoundException e) {
|
} catch (UserNotFoundException e) {
|
||||||
throw e;
|
throw e;
|
||||||
@@ -164,6 +160,9 @@ public class PortalUserAuthService implements UserDetailsService {
|
|||||||
|
|
||||||
String tempPassword = EncryptionUtil.generateNewPassword();
|
String tempPassword = EncryptionUtil.generateNewPassword();
|
||||||
portalUser.setPasswordHash(passwordEncoder.encode(tempPassword));
|
portalUser.setPasswordHash(passwordEncoder.encode(tempPassword));
|
||||||
|
// 임시 비밀번호 발급 → 변경일을 null 로 초기화해 로그인 시 강제 비밀번호 변경을 유도한다
|
||||||
|
// (LoginFinalizer.applyPostLoginState 의 passwordChangeDate == null 분기)
|
||||||
|
portalUser.setPasswordChangeDate(null);
|
||||||
if ("Y".equalsIgnoreCase(portalUser.getAccountLockYn())) {
|
if ("Y".equalsIgnoreCase(portalUser.getAccountLockYn())) {
|
||||||
portalUser.setAccountLockYn("N");
|
portalUser.setAccountLockYn("N");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -280,7 +280,7 @@ public class PortalUserService {
|
|||||||
return portalUserRepository.save(user);
|
return portalUserRepository.save(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void deleteUser(PortalUser user) {
|
public void deleteUser(PortalUser user, String withdrawalReason) {
|
||||||
LocalDateTime now = LocalDateTime.now();
|
LocalDateTime now = LocalDateTime.now();
|
||||||
String withdrawalDate = now.format(DateTimeFormatter.ofPattern("yyyyMMddHHmm"));
|
String withdrawalDate = now.format(DateTimeFormatter.ofPattern("yyyyMMddHHmm"));
|
||||||
|
|
||||||
@@ -290,6 +290,12 @@ public class PortalUserService {
|
|||||||
user.setMobileNumber("");
|
user.setMobileNumber("");
|
||||||
user.setPasswordHash("");
|
user.setPasswordHash("");
|
||||||
|
|
||||||
|
// 탈퇴 사유 보존 (컬럼 길이 200 초과분은 잘라 저장)
|
||||||
|
if (withdrawalReason != null && withdrawalReason.length() > 200) {
|
||||||
|
withdrawalReason = withdrawalReason.substring(0, 200);
|
||||||
|
}
|
||||||
|
user.setWithdrawalReason(withdrawalReason);
|
||||||
|
|
||||||
user.setUserStatus(PortalUserEnums.UserStatus.REMOVED);
|
user.setUserStatus(PortalUserEnums.UserStatus.REMOVED);
|
||||||
|
|
||||||
portalUserRepository.save(user);
|
portalUserRepository.save(user);
|
||||||
|
|||||||
+22
-1
@@ -3,6 +3,7 @@ package com.eactive.apim.portal.config;
|
|||||||
import com.eactive.apim.portal.apps.auth.twofactor.TwoFactorProperties;
|
import com.eactive.apim.portal.apps.auth.twofactor.TwoFactorProperties;
|
||||||
import com.eactive.apim.portal.apps.auth.twofactor.TwoFactorService;
|
import com.eactive.apim.portal.apps.auth.twofactor.TwoFactorService;
|
||||||
import com.eactive.apim.portal.apps.login.constants.LoginType;
|
import com.eactive.apim.portal.apps.login.constants.LoginType;
|
||||||
|
import com.eactive.apim.portal.apps.login.service.DuplicateLoginService;
|
||||||
import com.eactive.apim.portal.apps.login.service.LoginFinalizer;
|
import com.eactive.apim.portal.apps.login.service.LoginFinalizer;
|
||||||
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;
|
||||||
@@ -29,7 +30,9 @@ import java.io.IOException;
|
|||||||
* 비워 사용자를 익명으로 되돌린 뒤 {@code /login?twofactor=1} 로 보낸다. 로그인 페이지가
|
* 비워 사용자를 익명으로 되돌린 뒤 {@code /login?twofactor=1} 로 보낸다. 로그인 페이지가
|
||||||
* 공통 2FA 팝업을 자동 오픈하고, 인증 성공 시 {@code TwoFactorService} 가 최종 확정한다.</p>
|
* 공통 2FA 팝업을 자동 오픈하고, 인증 성공 시 {@code TwoFactorService} 가 최종 확정한다.</p>
|
||||||
*
|
*
|
||||||
* <p>2FA off(또는 DORMANT)면 {@link LoginFinalizer} 로 기존과 동일하게 즉시 확정한다.
|
* <p>2FA off 면 동시 접속(다른 곳 활성 세션) 여부를 확인해, 있으면 확정을 보류하고
|
||||||
|
* {@code /login?duplicate=1} 확인 팝업으로 유도한다({@link DuplicateLoginService}).
|
||||||
|
* 없으면(또는 DORMANT) {@link LoginFinalizer} 로 기존과 동일하게 즉시 확정한다.
|
||||||
* 실질 후처리 로직은 모두 {@link LoginFinalizer} 로 이관되어 로그인/2FA/가입자동로그인이 공유한다.</p>
|
* 실질 후처리 로직은 모두 {@link LoginFinalizer} 로 이관되어 로그인/2FA/가입자동로그인이 공유한다.</p>
|
||||||
*/
|
*/
|
||||||
@Service
|
@Service
|
||||||
@@ -41,6 +44,7 @@ public class PortalAuthenticationSuccessHandler implements AuthenticationSuccess
|
|||||||
private final LoginFinalizer loginFinalizer;
|
private final LoginFinalizer loginFinalizer;
|
||||||
private final TwoFactorService twoFactorService;
|
private final TwoFactorService twoFactorService;
|
||||||
private final TwoFactorProperties twoFactorProperties;
|
private final TwoFactorProperties twoFactorProperties;
|
||||||
|
private final DuplicateLoginService duplicateLoginService;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
|
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
|
||||||
@@ -71,6 +75,23 @@ public class PortalAuthenticationSuccessHandler implements AuthenticationSuccess
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2FA off: 동시 접속(다른 곳 활성 세션)이 있으면 확정을 보류하고 확인 팝업으로 유도한다.
|
||||||
|
// 중복 확인은 비밀번호 검증 통과 후에만 노출한다(사전 체크는 접속 여부/IP 정보 노출).
|
||||||
|
if (!dormant && duplicateLoginService.hasActiveSession(normalizedUsername)) {
|
||||||
|
user.setLoginFailureCount(0);
|
||||||
|
portalUserRepository.save(user);
|
||||||
|
|
||||||
|
HttpSession session = request.getSession();
|
||||||
|
duplicateLoginService.begin(session, user);
|
||||||
|
|
||||||
|
// 확인 완료 전까지 익명 상태로 되돌린다(보호 경로 자동 차단, LoginHandler 튕김 회피).
|
||||||
|
SecurityContextHolder.clearContext();
|
||||||
|
session.removeAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY);
|
||||||
|
|
||||||
|
response.sendRedirect(request.getContextPath() + "/login?duplicate=1");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 2FA off (또는 DORMANT) → 기존과 동일하게 즉시 확정
|
// 2FA off (또는 DORMANT) → 기존과 동일하게 즉시 확정
|
||||||
String redirect = loginFinalizer.finalizeLogin(user, username, request, LoginType.NORMAL);
|
String redirect = loginFinalizer.finalizeLogin(user, username, request, LoginType.NORMAL);
|
||||||
response.sendRedirect(redirect);
|
response.sendRedirect(redirect);
|
||||||
|
|||||||
@@ -108,7 +108,6 @@ public class PortalConfigSecurity {
|
|||||||
.csrf(csrf -> csrf
|
.csrf(csrf -> csrf
|
||||||
.csrfTokenRepository(csrfTokenRepository)
|
.csrfTokenRepository(csrfTokenRepository)
|
||||||
.ignoringRequestMatchers(new AntPathRequestMatcher("/_proxy/**/*"))
|
.ignoringRequestMatchers(new AntPathRequestMatcher("/_proxy/**/*"))
|
||||||
.ignoringRequestMatchers(new AntPathRequestMatcher("/api/session/check-duplicate"))
|
|
||||||
.ignoringRequestMatchers(new AntPathRequestMatcher("/internal/migration/**"))
|
.ignoringRequestMatchers(new AntPathRequestMatcher("/internal/migration/**"))
|
||||||
)
|
)
|
||||||
// 로그인 페이지에 오래 머물러 세션(=CSRF 토큰 저장소)이 타임아웃되면
|
// 로그인 페이지에 오래 머물러 세션(=CSRF 토큰 저장소)이 타임아웃되면
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -672,9 +672,36 @@ const customPopups = {
|
|||||||
|
|
||||||
$('body').css('overflow', 'hidden');
|
$('body').css('overflow', 'hidden');
|
||||||
|
|
||||||
|
// 열 때마다 사유/에러 초기화
|
||||||
|
$('#withdrawalReasonInput').val('');
|
||||||
|
$('#withdrawalReasonError').hide();
|
||||||
|
$('#withdrawalReasonInput').off('input.withdrawal').on('input.withdrawal', function () {
|
||||||
|
$('#withdrawalReasonError').hide();
|
||||||
|
});
|
||||||
|
|
||||||
$('#withdrawalPopupConfirmButton').off('click').on('click', function () {
|
$('#withdrawalPopupConfirmButton').off('click').on('click', function () {
|
||||||
|
// 탈퇴 사유 필수 입력
|
||||||
|
var reason = $.trim($('#withdrawalReasonInput').val() || '');
|
||||||
|
if (!reason) {
|
||||||
|
$('#withdrawalReasonError').show();
|
||||||
|
$('#withdrawalReasonInput').focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$('#withdrawalReasonHidden').val(reason);
|
||||||
|
|
||||||
customPopups.hideWithdrawal();
|
customPopups.hideWithdrawal();
|
||||||
$('#withdrawalForm').submit();
|
|
||||||
|
// step-up 2FA 활성 시: 인증 성공 후에만 제출
|
||||||
|
var twofaRequired = $('#withdrawalPopup').attr('data-twofa-required') === 'true';
|
||||||
|
if (twofaRequired && typeof TwoFactorAuth !== 'undefined') {
|
||||||
|
TwoFactorAuth.open({
|
||||||
|
purpose: '/withdraw',
|
||||||
|
onSuccess: function () { $('#withdrawalForm').submit(); },
|
||||||
|
onCancel: function () { /* 사용자 취소 — 탈퇴 중단 */ }
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
$('#withdrawalForm').submit();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
$('#withdrawalPopupCancelButton').off('click').on('click', function () {
|
$('#withdrawalPopupCancelButton').off('click').on('click', function () {
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Common Board Header Component Styling
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
.board-header {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
|
||||||
|
.board-title {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #000000;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-desc {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #7f8a95;
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,8 +44,9 @@
|
|||||||
@use 'components/breadcrumb' as *;
|
@use 'components/breadcrumb' as *;
|
||||||
@use 'components/test-env-notice' as *;
|
@use 'components/test-env-notice' as *;
|
||||||
@use 'components/djb-inquiry-comments' as *;
|
@use 'components/djb-inquiry-comments' as *;
|
||||||
@use 'components/password-policy' as *;
|
@use 'components/board-common' as *;
|
||||||
@use 'components/two-factor' as *;
|
@use 'components/two-factor' as *;
|
||||||
|
@use 'components/password-policy' as *;
|
||||||
|
|
||||||
// 5. Page-specific styles
|
// 5. Page-specific styles
|
||||||
@use 'pages/index' as *;
|
@use 'pages/index' as *;
|
||||||
|
|||||||
@@ -8,7 +8,6 @@
|
|||||||
|
|
||||||
.api-statistics-container {
|
.api-statistics-container {
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 48px 0px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 집계 안내 문구
|
// 집계 안내 문구
|
||||||
@@ -34,10 +33,11 @@
|
|||||||
// Search Section
|
// Search Section
|
||||||
.statistics-search-section {
|
.statistics-search-section {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
margin-bottom: 30px;
|
margin-bottom: 30px;
|
||||||
|
margin-top: 50px;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 일별/월별 모드 토글
|
// 일별/월별 모드 토글
|
||||||
@@ -46,6 +46,7 @@
|
|||||||
background: #eef1f5;
|
background: #eef1f5;
|
||||||
border-radius: 22px;
|
border-radius: 22px;
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
|
width: 150px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mode-tab {
|
.mode-tab {
|
||||||
@@ -347,9 +348,10 @@
|
|||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
stroke: #fff;
|
stroke: #1e293b;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -375,39 +377,89 @@
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.statistics-table {
|
.board-table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-top: 1px solid #c4c7c8;
|
||||||
|
background: #fff;
|
||||||
|
|
||||||
thead th {
|
.board-table-header {
|
||||||
padding: 16px 20px;
|
display: flex;
|
||||||
text-align: left;
|
align-items: center;
|
||||||
font-weight: 500;
|
background: #f9f9f9;
|
||||||
color: #666;
|
height: 50px;
|
||||||
border-bottom: 1px solid #e0e0e0;
|
padding: 0 45px;
|
||||||
background: #fafafa;
|
border-bottom: 1px solid #dedede;
|
||||||
}
|
|
||||||
|
|
||||||
tbody {
|
@media (max-width: 768px) {
|
||||||
td {
|
display: none;
|
||||||
padding: 16px 20px;
|
|
||||||
border-bottom: 1px solid #f0f0f0;
|
|
||||||
color: #333;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
tr:hover {
|
.header-cell {
|
||||||
background: #f9f9f9;
|
font-size: 14px;
|
||||||
|
font-family: 'Spoqa Han Sans Neo', sans-serif;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #000;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
tfoot .total-row {
|
.board-table-body {
|
||||||
background: #e3f2fd;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
td {
|
.board-table-row {
|
||||||
padding: 16px 20px;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 52px;
|
||||||
|
padding: 0 45px;
|
||||||
|
border-bottom: 1px solid #dedede;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
background: #ffffff;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: #f8fbfe;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
height: auto;
|
||||||
|
padding: 15px;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: 'Spoqa Han Sans Neo', sans-serif;
|
||||||
|
font-weight: 400;
|
||||||
|
color: #000;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
justify-content: flex-start;
|
||||||
|
width: 100% !important;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: attr(data-label);
|
||||||
|
font-weight: 500;
|
||||||
|
color: #64748b;
|
||||||
|
margin-right: 10px;
|
||||||
|
min-width: 80px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.total-row {
|
||||||
|
background: #f8fbfe;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #0049B4;
|
|
||||||
border-bottom: none;
|
.row-cell {
|
||||||
|
color: #0049B4;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -440,39 +492,64 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Progress Bar
|
.api-name-text {
|
||||||
.progress-cell {
|
display: inline-block;
|
||||||
min-width: 200px;
|
vertical-align: middle;
|
||||||
|
max-width: 180px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
max-width: 320px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-bar {
|
// Progress Bar
|
||||||
|
.col-name {
|
||||||
|
flex: 2;
|
||||||
|
justify-content: flex-start !important;
|
||||||
|
padding-left: 10px;
|
||||||
|
min-width: 0; // Allows flex child truncation
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-table-header .col-name {
|
||||||
|
justify-content: center !important;
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-num {
|
||||||
|
width: 100px;
|
||||||
|
justify-content: center !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-chart {
|
||||||
|
width: 180px;
|
||||||
|
justify-content: center !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-progress {
|
||||||
|
width: 150px;
|
||||||
|
height: 8px;
|
||||||
|
background: #f1f5f9;
|
||||||
|
border-radius: 4px;
|
||||||
display: flex;
|
display: flex;
|
||||||
height: 12px;
|
|
||||||
background: #e0e0e0;
|
|
||||||
border-radius: 6px;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-success {
|
.stats-empty {
|
||||||
background: #4A90D9;
|
display: flex;
|
||||||
transition: width 0.3s ease;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100px;
|
||||||
|
width: 100%;
|
||||||
|
color: #64748b;
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-timeout {
|
.progress-cell {
|
||||||
background: #F5C36B;
|
min-width: 200px;
|
||||||
transition: width 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-failure {
|
|
||||||
background: #F5A3B5;
|
|
||||||
transition: width 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Empty State
|
|
||||||
.empty-row td {
|
|
||||||
text-align: center;
|
|
||||||
padding: 40px 20px;
|
|
||||||
color: #999;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Responsive
|
// Responsive
|
||||||
@@ -480,6 +557,7 @@
|
|||||||
.summary-content {
|
.summary-content {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 30px;
|
gap: 30px;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.summary-left,
|
.summary-left,
|
||||||
@@ -487,6 +565,22 @@
|
|||||||
.summary-right {
|
.summary-right {
|
||||||
flex: none;
|
flex: none;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-info {
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-select-wrapper {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-detail-list {
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.summary-center {
|
.summary-center {
|
||||||
@@ -495,42 +589,101 @@
|
|||||||
|
|
||||||
.summary-right {
|
.summary-right {
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
|
flex-wrap: wrap;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rate-display {
|
||||||
|
border-bottom: none;
|
||||||
|
padding: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: $breakpoint-sm) {
|
||||||
.api-statistics-container {
|
// .api-statistics-container {
|
||||||
padding: 20px 16px;
|
// padding: 20px 16px;
|
||||||
}
|
// }
|
||||||
|
|
||||||
.statistics-summary-card {
|
.statistics-summary-card {
|
||||||
padding: 24px 20px;
|
padding: 24px 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-toggle-buttons {
|
.statistics-search-section {
|
||||||
flex-wrap: wrap;
|
flex-direction: column;
|
||||||
}
|
align-items: stretch;
|
||||||
|
gap: 16px;
|
||||||
.toggle-btn {
|
|
||||||
flex: 1;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.statistics-table {
|
|
||||||
thead th,
|
|
||||||
tbody td,
|
|
||||||
tfoot td {
|
|
||||||
padding: 12px 10px;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-cell {
|
|
||||||
min-width: 100px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-filter-row {
|
.search-filter-row {
|
||||||
flex-wrap: wrap;
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-range-picker {
|
||||||
|
flex: 1;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-input {
|
||||||
|
width: 45%;
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-table-header {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-table-row {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
height: auto;
|
||||||
|
padding: 10px 14px;
|
||||||
|
gap: 6px;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
|
||||||
|
.row-cell {
|
||||||
|
display: flex !important;
|
||||||
|
justify-content: space-between !important;
|
||||||
|
align-items: center;
|
||||||
|
width: 100% !important;
|
||||||
|
text-align: right;
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 0 !important;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: attr(data-label);
|
||||||
|
font-weight: 500;
|
||||||
|
color: #64748b;
|
||||||
|
text-align: left;
|
||||||
|
min-width: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.col-name {
|
||||||
|
font-weight: 600;
|
||||||
|
justify-content: flex-start !important;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: none;
|
||||||
|
padding-bottom: 0 !important;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
display: flex !important;
|
||||||
|
gap: 10px;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
// content: "API명" !important;
|
||||||
|
// display: inline-block !important;
|
||||||
|
// font-weight: 500;
|
||||||
|
// color: #64748b;
|
||||||
|
// min-width: 80px;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-progress {
|
||||||
|
width: 100px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1240,6 +1240,36 @@
|
|||||||
color: #64748b;
|
color: #64748b;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.info-notice-box {
|
||||||
|
background: #ecf0fa;
|
||||||
|
border-radius: 7px;
|
||||||
|
padding: 13px 14px 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 13px;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
color: #4685ef;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.danger {
|
||||||
|
background: #fdf3f3;
|
||||||
|
p {
|
||||||
|
color: #f4253c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Footer Buttons layout
|
// Footer Buttons layout
|
||||||
.dt-actions {
|
.dt-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -10,12 +10,11 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
background: #EDF9FE; // Light blue background from Figma
|
padding: 100px 20px;
|
||||||
padding: 40px 20px;
|
|
||||||
position: relative;
|
position: relative;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
margin-top: 60px;
|
margin-top: 60px;
|
||||||
margin-bottom: 60px;
|
margin-bottom: 50px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-container {
|
.login-container {
|
||||||
@@ -37,60 +36,104 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-logo {
|
.login-header {
|
||||||
width: 90px;
|
display: flex;
|
||||||
height: 90px;
|
flex-direction: column;
|
||||||
margin-bottom: 30px;
|
align-items: center;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
img {
|
.header-main-row {
|
||||||
width: 100%;
|
display: flex;
|
||||||
height: 100%;
|
align-items: center;
|
||||||
object-fit: contain;
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-message {
|
.login-lock-icon {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
stroke: #000;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-title {
|
||||||
font-family: $font-family-primary;
|
font-family: $font-family-primary;
|
||||||
font-size: 20px;
|
font-size: 36px;
|
||||||
font-weight: 400;
|
font-weight: 700;
|
||||||
color: #000000;
|
color: #000000;
|
||||||
text-align: center;
|
margin: 0;
|
||||||
margin-bottom: 40px;
|
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.login-message-sub {
|
||||||
|
font-family: $font-family-primary;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 300;
|
||||||
|
color: #80878d;
|
||||||
|
text-align: center;
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
.login-form {
|
.login-form {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
|
|
||||||
.form-group {
|
.form-group {
|
||||||
margin-bottom: 24px;
|
margin-bottom: 16px;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
&:last-of-type {
|
&.input-with-icon {
|
||||||
margin-bottom: 0;
|
.input-icon {
|
||||||
|
position: absolute;
|
||||||
|
left: 20px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 2;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
stroke: #767e89;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input {
|
||||||
|
padding-left: 56px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-input {
|
.form-input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 70px;
|
height: 58px;
|
||||||
padding: 0 32px;
|
padding: 0 20px;
|
||||||
font-family: $font-family-primary;
|
font-family: $font-family-primary;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 400;
|
font-weight: 300;
|
||||||
color: #5F666C;
|
color: #000000;
|
||||||
background: #FFFFFF;
|
background: #fcfcfc;
|
||||||
border: 1px solid #DDDDDD;
|
border: 1px solid #dfdfdf;
|
||||||
border-radius: 12px;
|
border-radius: 5px;
|
||||||
outline: none;
|
outline: none;
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
|
|
||||||
&::placeholder {
|
&::placeholder {
|
||||||
color: #5F666C;
|
color: #767e89;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:focus {
|
&:focus {
|
||||||
border-color: #0049B4;
|
border-color: #1b4ab7;
|
||||||
|
background: #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.error {
|
&.error {
|
||||||
@@ -101,17 +144,16 @@
|
|||||||
.form-checkbox {
|
.form-checkbox {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: flex-end;
|
justify-content: flex-start;
|
||||||
gap: 7px;
|
gap: 8px;
|
||||||
margin-top: 24px;
|
margin-top: 14px;
|
||||||
margin-bottom: 22px;
|
margin-bottom: 30px;
|
||||||
margin-right: 10px;
|
|
||||||
|
|
||||||
input[type="checkbox"] {
|
input[type="checkbox"] {
|
||||||
width: 20px;
|
width: 14px;
|
||||||
height: 20px;
|
height: 14px;
|
||||||
border: 1.5px solid #000000;
|
border: 1px solid #dfdfdf;
|
||||||
border-radius: 4px;
|
border-radius: 2px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
appearance: none;
|
appearance: none;
|
||||||
background: #FFFFFF;
|
background: #FFFFFF;
|
||||||
@@ -119,8 +161,8 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
|
||||||
&:checked {
|
&:checked {
|
||||||
background: #FFFFFF;
|
background: #1b4ab7;
|
||||||
border-color: #2C2C2C;
|
border-color: #1b4ab7;
|
||||||
|
|
||||||
&::after {
|
&::after {
|
||||||
content: '';
|
content: '';
|
||||||
@@ -128,22 +170,22 @@
|
|||||||
left: 50%;
|
left: 50%;
|
||||||
top: 45%;
|
top: 45%;
|
||||||
transform: translate(-50%, -50%) rotate(45deg);
|
transform: translate(-50%, -50%) rotate(45deg);
|
||||||
width: 6px;
|
width: 3px;
|
||||||
height: 12px;
|
height: 6px;
|
||||||
border: solid #2C2C2C;
|
border: solid #ffffff;
|
||||||
border-width: 0 2px 2px 0;
|
border-width: 0 1.5px 1.5px 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
label {
|
label {
|
||||||
font-family: $font-family-primary;
|
font-family: $font-family-primary;
|
||||||
font-size: 16px;
|
font-size: 12px;
|
||||||
font-weight: 400;
|
font-weight: 300;
|
||||||
color: #000000;
|
color: #767e89;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
line-height: 24px;
|
line-height: 1;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -151,26 +193,26 @@
|
|||||||
|
|
||||||
.login-button {
|
.login-button {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 80px;
|
height: 58px;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
font-family: $font-family-primary;
|
font-family: $font-family-primary;
|
||||||
font-size: 20px;
|
font-size: 16px;
|
||||||
font-weight: 700;
|
font-weight: 500;
|
||||||
color: #FFFFFF;
|
color: #FFFFFF;
|
||||||
background: #0049B4;
|
background: #1b4ab7;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 12px;
|
border-radius: 5px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
margin-bottom: 40px;
|
margin-bottom: 30px;
|
||||||
line-height: 1;
|
line-height: 20px;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: darken(#0049B4, 5%);
|
background: darken(#1b4ab7, 5%);
|
||||||
}
|
}
|
||||||
|
|
||||||
&:active {
|
&:active {
|
||||||
background: darken(#0049B4, 10%);
|
background: darken(#1b4ab7, 10%);
|
||||||
}
|
}
|
||||||
|
|
||||||
&:disabled {
|
&:disabled {
|
||||||
@@ -179,31 +221,51 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-links {
|
.login-links-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 26px;
|
width: 100%;
|
||||||
flex-wrap: wrap;
|
|
||||||
|
|
||||||
a {
|
.link-btn {
|
||||||
|
flex: 1;
|
||||||
|
height: 38px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: #ecf0fa;
|
||||||
|
border: 1px solid #2a69de;
|
||||||
font-family: $font-family-primary;
|
font-family: $font-family-primary;
|
||||||
font-size: 16px;
|
font-size: 14px;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
color: #000000;
|
color: #2a69de;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
transition: color 0.3s ease;
|
transition: all 0.2s ease;
|
||||||
line-height: 1;
|
line-height: 20px;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
&.first {
|
||||||
|
border-top-left-radius: 8px;
|
||||||
|
border-bottom-left-radius: 8px;
|
||||||
|
border-right: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.middle {
|
||||||
|
border-right: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.last {
|
||||||
|
border-top-right-radius: 8px;
|
||||||
|
border-bottom-right-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: #0049B4;
|
background: darken(#ecf0fa, 4%);
|
||||||
text-decoration: underline;
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
.link-separator {
|
&:active {
|
||||||
color: #000000;
|
background: darken(#ecf0fa, 8%);
|
||||||
font-size: 16px;
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -282,9 +344,8 @@
|
|||||||
@include respond-to('sm') {
|
@include respond-to('sm') {
|
||||||
.login-page {
|
.login-page {
|
||||||
// Figma: 전체 배경 #ebfbff, 풀 높이
|
// Figma: 전체 배경 #ebfbff, 풀 높이
|
||||||
background: #ebfbff;
|
|
||||||
min-height: calc(100vh - 44px); // 모바일 헤더 높이 제외
|
min-height: calc(100vh - 44px); // 모바일 헤더 높이 제외
|
||||||
padding: 40px 20px;
|
padding: 100px 20px;
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
|
|||||||
@@ -26,13 +26,14 @@
|
|||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
// Corporate Manager Profile Form Styles
|
// Corporate Manager Profile Form Styles
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
.corp-manager-wrapper, .password-change-wrapper {
|
.corp-manager-wrapper,
|
||||||
|
.password-change-wrapper {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 40px 0;
|
// padding: 40px 0;
|
||||||
|
|
||||||
.page-outer-title {
|
.page-outer-title {
|
||||||
font-size: 36px;
|
font-size: 24px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #000;
|
color: #000;
|
||||||
margin-bottom: 24px;
|
margin-bottom: 24px;
|
||||||
@@ -81,12 +82,17 @@
|
|||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
gap: 34px;
|
gap: 34px;
|
||||||
|
|
||||||
> div {
|
>div {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 13px;
|
gap: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: $breakpoint-sm) {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 26px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,7 +224,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-top: 40px;
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-withdrawal {
|
.btn-withdrawal {
|
||||||
@@ -291,6 +297,14 @@
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.danger {
|
||||||
|
background: #fdf3f3;
|
||||||
|
|
||||||
|
p {
|
||||||
|
color: #f4253c;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.password-rules-list {
|
.password-rules-list {
|
||||||
|
|||||||
@@ -93,7 +93,9 @@
|
|||||||
th:if="${notice.noticeType == '4'}">테스트</span>
|
th:if="${notice.noticeType == '4'}">테스트</span>
|
||||||
<span class="notice-type-badge notice-type-badge--normal"
|
<span class="notice-type-badge notice-type-badge--normal"
|
||||||
th:if="${notice.noticeType == '1'}">공지</span>
|
th:if="${notice.noticeType == '1'}">공지</span>
|
||||||
<span th:text="${#strings.replace(#strings.replace(#strings.replace(#strings.replace(notice.noticeSubject, '[장애]', ''), '[점검]', ''), '[테스트]', ''), '[공지]', '')}">공지사항 제목</span>
|
<span
|
||||||
|
th:text="${#strings.replace(#strings.replace(#strings.replace(#strings.replace(notice.noticeSubject, '[장애]', ''), '[점검]', ''), '[테스트]', ''), '[공지]', '')}">공지사항
|
||||||
|
제목</span>
|
||||||
<span class="file-icon" th:if="${notice.fileId != null and !notice.fileId.isEmpty()}">
|
<span class="file-icon" th:if="${notice.fileId != null and !notice.fileId.isEmpty()}">
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<path
|
<path
|
||||||
|
|||||||
@@ -39,11 +39,9 @@
|
|||||||
<section class="service-content">
|
<section class="service-content">
|
||||||
<!-- 상단: 최근 작성한 글 목록 -->
|
<!-- 상단: 최근 작성한 글 목록 -->
|
||||||
<div class="recent-apps">
|
<div class="recent-apps">
|
||||||
<div class="recent-apps-header-group">
|
<div class="board-header">
|
||||||
<p class="recent-apps-title" style="font-size: 24px; font-weight: 700; color: #000; margin-bottom: 8px;">
|
<h2 class="board-title">피드백 / 개선 요청</h2>
|
||||||
피드백 / 개선 요청</p>
|
<p class="board-desc">최근 최대 3개의 나의 피드백/ 개선 요청을 보여줍니다.</p>
|
||||||
<p class="recent-apps-subtitle" style="font-size: 14px; color: #7f8a95; margin-bottom: 24px;">최근 최대 3개의 나의
|
|
||||||
피드백/ 개선 요청을 보여줍니다.</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul class="recent-apps-list" th:if="${recentApplications != null and !recentApplications.isEmpty()}">
|
<ul class="recent-apps-list" th:if="${recentApplications != null and !recentApplications.isEmpty()}">
|
||||||
@@ -73,10 +71,10 @@
|
|||||||
<!-- 하단: 신규 작성 폼 카드 -->
|
<!-- 하단: 신규 작성 폼 카드 -->
|
||||||
<div class="djb-board-write-container">
|
<div class="djb-board-write-container">
|
||||||
<!-- Title Bar -->
|
<!-- Title Bar -->
|
||||||
<div class="djb-board-write-header" style="margin-bottom: 30px;">
|
<div class="board-header">
|
||||||
<h2 class="title" style="font-size: 24px; font-weight: 700; color: #000; margin-bottom: 8px;">피드백 / 개선 요청
|
<h2 class="board-title">피드백 / 개선 요청
|
||||||
</h2>
|
</h2>
|
||||||
<p class="subtitle" style="font-size: 14px; color: #7f8a95; margin: 0;">DJ Bank은 온라인 비즈니스 혁신을 위한 피드백/개선요청을
|
<p class="board-desc">DJ Bank은 온라인 비즈니스 혁신을 위한 피드백/개선요청을
|
||||||
환영합니다.</p>
|
환영합니다.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
<div class="found-users-list">
|
<div class="found-users-list">
|
||||||
<div class="found-user-item" th:each="user, stat : ${foundUsers}">
|
<div class="found-user-item" th:each="user, stat : ${foundUsers}">
|
||||||
<div class="user-info">
|
<div class="user-info">
|
||||||
<span class="user-email" th:text="${user.maskedEmailAddr}">te***@example.com</span>
|
<span class="user-email" th:text="${user.loginId}">test@example.com</span>
|
||||||
<span class="user-date">
|
<span class="user-date">
|
||||||
(<th:block th:text="${#temporals.format(user.createdDate, 'yyyy.MM.dd')}">2024.01.01</th:block> 가입)
|
(<th:block th:text="${#temporals.format(user.createdDate, 'yyyy.MM.dd')}">2024.01.01</th:block> 가입)
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -1,93 +1,105 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
|
<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/djbank_base_layout}">
|
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/djbank_base_layout}">
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<th:block layout:fragment="contentFragment">
|
<th:block layout:fragment="contentFragment">
|
||||||
<div class="login-page">
|
<div class="login-page">
|
||||||
<div class="login-container">
|
<div class="login-container">
|
||||||
<div class="login-card">
|
<div class="login-card">
|
||||||
<!-- Logo -->
|
<!-- Logo & Title -->
|
||||||
<div class="login-logo">
|
<div class="login-header">
|
||||||
<img th:src="@{/img/logo/logo_box.png}" alt="DJBank">
|
<div class="header-main-row">
|
||||||
</div>
|
<div class="login-lock-icon">
|
||||||
|
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="#000"
|
||||||
<!-- Header Message -->
|
stroke-width="2">
|
||||||
<p class="login-message">로그인 하시면 더 많은 서비스를 이용하실 수 있습니다.</p>
|
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
|
||||||
|
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||||||
<!-- Alert Messages -->
|
</svg>
|
||||||
<div th:if="${param.logout}" class="login-alert alert-info">
|
</div>
|
||||||
<i class="fas fa-info-circle"></i>
|
<h2 class="login-title">로그인</h2>
|
||||||
<span>로그아웃되었습니다.</span>
|
</div>
|
||||||
</div>
|
<p class="login-message-sub">DJ Bank에 오신걸 환영합니다</p>
|
||||||
<div th:if="${param.expired}" class="login-alert alert-info">
|
|
||||||
<i class="fas fa-info-circle"></i>
|
|
||||||
<span>장시간 미사용으로 세션이 만료되어 로그아웃되었습니다. 다시 로그인해주세요.</span>
|
|
||||||
</div>
|
|
||||||
<div th:if="${param.forceLogout}" class="login-alert alert-info">
|
|
||||||
<i class="fas fa-info-circle"></i>
|
|
||||||
<span>다른 기기 또는 브라우저에서 로그인되어 현재 세션이 종료되었습니다.</span>
|
|
||||||
</div>
|
|
||||||
<div th:if="${param.reason == 'auth'}" class="login-alert alert-info">
|
|
||||||
<i class="fas fa-info-circle"></i>
|
|
||||||
<span>인증된 사용자만 접근 가능한 페이지입니다. 로그인 후 이용해 주세요.</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Login Form -->
|
|
||||||
<form id="loginForm" role="form" name="loginForm" th:action="@{/actionLogin.do}" method="post" class="login-form">
|
|
||||||
<input type="hidden" id="message" name="message" th:value="${loginMessage}">
|
|
||||||
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/>
|
|
||||||
<input type="hidden" id="loginId" th:value="${loginId}">
|
|
||||||
|
|
||||||
<!-- ID Field -->
|
|
||||||
<div class="form-group">
|
|
||||||
<input type="text"
|
|
||||||
id="id"
|
|
||||||
name="id"
|
|
||||||
th:value="${loginId}"
|
|
||||||
class="form-input"
|
|
||||||
placeholder="아이디"
|
|
||||||
required>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Password Field -->
|
<!-- Alert Messages -->
|
||||||
<div class="form-group">
|
<div th:if="${param.logout}" class="login-alert alert-info">
|
||||||
<input type="password"
|
<i class="fas fa-info-circle"></i>
|
||||||
id="password"
|
<span>로그아웃되었습니다.</span>
|
||||||
name="password"
|
</div>
|
||||||
class="form-input"
|
<div th:if="${param.expired}" class="login-alert alert-info">
|
||||||
placeholder="비밀번호"
|
<i class="fas fa-info-circle"></i>
|
||||||
required>
|
<span>장시간 미사용으로 세션이 만료되어 로그아웃되었습니다. 다시 로그인해주세요.</span>
|
||||||
|
</div>
|
||||||
|
<div th:if="${param.forceLogout}" class="login-alert alert-info">
|
||||||
|
<i class="fas fa-info-circle"></i>
|
||||||
|
<span>다른 기기 또는 브라우저에서 로그인되어 현재 세션이 종료되었습니다.</span>
|
||||||
|
</div>
|
||||||
|
<div th:if="${param.reason == 'auth'}" class="login-alert alert-info">
|
||||||
|
<i class="fas fa-info-circle"></i>
|
||||||
|
<span>인증된 사용자만 접근 가능한 페이지입니다. 로그인 후 이용해 주세요.</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Remember Me Checkbox -->
|
<!-- Login Form -->
|
||||||
<div class="form-checkbox">
|
<form id="loginForm" role="form" name="loginForm" th:action="@{/actionLogin.do}" method="post"
|
||||||
<input id="checkId" name="checkId" type="checkbox">
|
class="login-form">
|
||||||
<label for="checkId">아이디저장</label>
|
<input type="hidden" id="message" name="message" th:value="${loginMessage}">
|
||||||
|
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
|
||||||
|
<input type="hidden" id="loginId" th:value="${loginId}">
|
||||||
|
|
||||||
|
<!-- ID Field -->
|
||||||
|
<div class="form-group input-with-icon">
|
||||||
|
<span class="input-icon">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#767e89"
|
||||||
|
stroke-width="2">
|
||||||
|
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
|
||||||
|
<circle cx="12" cy="7" r="4" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<input type="text" id="id" name="id" th:value="${loginId}" class="form-input"
|
||||||
|
placeholder="아이디를 입력해주세요" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Password Field -->
|
||||||
|
<div class="form-group input-with-icon">
|
||||||
|
<span class="input-icon">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#767e89"
|
||||||
|
stroke-width="2">
|
||||||
|
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
|
||||||
|
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<input type="password" id="password" name="password" class="form-input"
|
||||||
|
placeholder="비밀번호를 입력해주세요" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Remember Me Checkbox -->
|
||||||
|
<div class="form-checkbox">
|
||||||
|
<input id="checkId" name="checkId" type="checkbox">
|
||||||
|
<label for="checkId">아이디 저장</label>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Login Button -->
|
||||||
|
<button type="button" class="login-button" id="actionLogin">
|
||||||
|
로그인
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Additional Links -->
|
||||||
|
<div class="login-links-container">
|
||||||
|
<a th:href="@{/account_recovery(tab='findId')}" class="link-btn first">아이디 찾기</a>
|
||||||
|
<a th:href="@{/account_recovery(tab='resetPassword')}" class="link-btn middle">비밀번호 초기화</a>
|
||||||
|
<a th:href="@{/signup}" class="link-btn last">회원가입</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
|
||||||
|
|
||||||
<!-- Login Button -->
|
<!-- Loading State -->
|
||||||
<button type="button" class="login-button" id="actionLogin">
|
<div class="login-loading" id="loginLoading">
|
||||||
로그인
|
<div class="spinner"></div>
|
||||||
</button>
|
</div>
|
||||||
|
|
||||||
<!-- Additional Links -->
|
|
||||||
<div class="login-links">
|
|
||||||
<a th:href="@{/account_recovery(tab='findId')}">아이디 찾기</a>
|
|
||||||
<span class="link-separator">|</span>
|
|
||||||
<a th:href="@{/account_recovery(tab='resetPassword')}">비밀번호 초기화</a>
|
|
||||||
<span class="link-separator">|</span>
|
|
||||||
<a th:href="@{/signup}">회원가입</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Loading State -->
|
|
||||||
<div class="login-loading" id="loginLoading">
|
|
||||||
<div class="spinner"></div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</th:block>
|
||||||
</th:block>
|
|
||||||
</body>
|
</body>
|
||||||
<th:block layout:fragment="contentScript">
|
<th:block layout:fragment="contentScript">
|
||||||
<script th:inline="javascript">
|
<script th:inline="javascript">
|
||||||
@@ -137,34 +149,21 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 로그인 전 중복 접속 확인 → 중복 시 기존 세션 강제 로그아웃 여부 질의
|
// 동시 접속 확인은 비밀번호 검증(1차 인증) 통과 후 서버가 안내한다
|
||||||
function checkDuplicateAndLogin(form) {
|
// (2FA pending 또는 /login?duplicate=1 대기 상태에서 duplicateInfo 모델로 수신).
|
||||||
var loginId = form.id.value;
|
function duplicateConfirmMessage(info) {
|
||||||
$.ajax({
|
return '해당 계정은 이미 다른 곳(' + info.ipAddress + ')에서 접속 중입니다.<br>'
|
||||||
url: /*[[@{/api/session/check-duplicate}]]*/ '/api/session/check-duplicate',
|
+ '접속 시각: ' + info.loginTime + '<br><br>'
|
||||||
type: 'POST',
|
+ '기존 접속을 해제하고 로그인하시겠습니까?';
|
||||||
data: { loginId: loginId },
|
}
|
||||||
success: function (data) {
|
|
||||||
if (data && data.duplicateSession) {
|
// 세션 CSRF 헤더 (meta[name=_csrf]) — 인증 후 확인/취소 POST 용
|
||||||
$('#loginLoading').removeClass('active');
|
function csrfHeaders() {
|
||||||
var msg = '해당 계정은 이미 다른 곳(' + data.ipAddress + ')에서 접속 중입니다.<br>'
|
var t = document.querySelector('meta[name="_csrf"]');
|
||||||
+ '접속 시각: ' + data.loginTime + '<br><br>'
|
var h = document.querySelector('meta[name="_csrf_header"]');
|
||||||
+ '기존 접속을 해제하고 로그인하시겠습니까?';
|
var headers = {};
|
||||||
customPopups.showConfirm(msg, function (confirmed) {
|
headers[h ? h.getAttribute('content') : 'X-XSRF-TOKEN'] = t ? t.getAttribute('content') : '';
|
||||||
if (confirmed) {
|
return headers;
|
||||||
$('#loginLoading').addClass('active');
|
|
||||||
form.submit();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
form.submit();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
error: function () {
|
|
||||||
// 중복 체크 실패 시 로그인은 그대로 진행
|
|
||||||
form.submit();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function fnInit() {
|
function fnInit() {
|
||||||
@@ -182,7 +181,7 @@
|
|||||||
// 세션 만료 대응은 head의 익명 keepalive ping(/api/session/ping)이 담당하고,
|
// 세션 만료 대응은 head의 익명 keepalive ping(/api/session/ping)이 담당하고,
|
||||||
// 제출 직전 refreshCsrfAndThen()이 CSRF 토큰 재발급 안전망 역할을 한다.
|
// 제출 직전 refreshCsrfAndThen()이 CSRF 토큰 재발급 안전망 역할을 한다.
|
||||||
|
|
||||||
var successMsg = [[${success}]];
|
var successMsg = [[${ success }]];
|
||||||
console.log("Success message:", successMsg);
|
console.log("Success message:", successMsg);
|
||||||
if (successMsg) {
|
if (successMsg) {
|
||||||
console.log("Showing alert");
|
console.log("Showing alert");
|
||||||
@@ -190,13 +189,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 비밀번호 만료 체크 관련 코드 추가
|
// 비밀번호 만료 체크 관련 코드 추가
|
||||||
var successMsg = [[${session.success}]];
|
var successMsg = [[${ session.success }]];
|
||||||
var passwordExpired = [[${session.passwordExpired}]];
|
var passwordExpired = [[${ session.passwordExpired }]];
|
||||||
var emailVerificationRequired = [[${session.emailVerificationRequired}]];
|
var emailVerificationRequired = [[${ session.emailVerificationRequired }]];
|
||||||
var redirectUrl = [[${session.redirectUrl}]];
|
var redirectUrl = [[${ session.redirectUrl }]];
|
||||||
|
|
||||||
if (successMsg) {
|
if (successMsg) {
|
||||||
customPopups.showAlert(successMsg, function() {
|
customPopups.showAlert(successMsg, function () {
|
||||||
// 비밀번호 만료·미인증(이메일 인증 필요) 시 전용 화면으로 이동
|
// 비밀번호 만료·미인증(이메일 인증 필요) 시 전용 화면으로 이동
|
||||||
if ((passwordExpired || emailVerificationRequired) && redirectUrl) {
|
if ((passwordExpired || emailVerificationRequired) && redirectUrl) {
|
||||||
window.location.href = redirectUrl;
|
window.location.href = redirectUrl;
|
||||||
@@ -230,14 +229,14 @@
|
|||||||
customPopups.showAlert('[[#{login.passLengthShort}]]');
|
customPopups.showAlert('[[#{login.passLengthShort}]]');
|
||||||
} else {
|
} else {
|
||||||
refreshCsrfAndThen(form, function () {
|
refreshCsrfAndThen(form, function () {
|
||||||
checkDuplicateAndLogin(form);
|
form.submit();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
form.classList.add('was-validated');
|
form.classList.add('was-validated');
|
||||||
};
|
};
|
||||||
|
|
||||||
$('#actionLogin').on('click', function(event) {
|
$('#actionLogin').on('click', function (event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
actionLogin(event);
|
actionLogin(event);
|
||||||
});
|
});
|
||||||
@@ -248,9 +247,12 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 로그인 2FA: 1차 인증 통과 후 pending 상태면 추가 인증 팝업 자동 오픈
|
// 1차 인증(비밀번호) 통과 후 흐름: [동시 접속 확인] → [2FA] 순서.
|
||||||
var twoFactorPending = [[${twoFactorPending}]];
|
var twoFactorPending = [[${twoFactorPending}]];
|
||||||
if (twoFactorPending && typeof TwoFactorAuth !== 'undefined') {
|
var duplicateConfirmPending = [[${duplicateConfirmPending}]];
|
||||||
|
var duplicateInfo = [[${duplicateInfo}]];
|
||||||
|
|
||||||
|
function openLoginTwoFactor() {
|
||||||
TwoFactorAuth.open({
|
TwoFactorAuth.open({
|
||||||
mode: 'login',
|
mode: 'login',
|
||||||
onSuccess: function (res) {
|
onSuccess: function (res) {
|
||||||
@@ -267,8 +269,56 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (twoFactorPending && typeof TwoFactorAuth !== 'undefined') {
|
||||||
|
// 로그인 2FA pending: 동시 접속이 있으면 확인 후 2FA 팝업, 취소 시 로그인 포기
|
||||||
|
if (duplicateInfo) {
|
||||||
|
customPopups.showConfirm(duplicateConfirmMessage(duplicateInfo), function (confirmed) {
|
||||||
|
if (confirmed) {
|
||||||
|
openLoginTwoFactor();
|
||||||
|
} else {
|
||||||
|
$.ajax({
|
||||||
|
url: /*[[@{/auth/2fa/cancel}]]*/ '/auth/2fa/cancel',
|
||||||
|
type: 'POST',
|
||||||
|
headers: csrfHeaders(),
|
||||||
|
data: { reason: 'CANCELLED' }
|
||||||
|
});
|
||||||
|
customPopups.showAlert('로그인이 취소되었습니다.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
openLoginTwoFactor();
|
||||||
|
}
|
||||||
|
} else if (duplicateConfirmPending && duplicateInfo) {
|
||||||
|
// 2FA off + 동시 접속: 확인 시 서버가 로그인 확정(기존 접속 해제), 취소 시 포기
|
||||||
|
customPopups.showConfirm(duplicateConfirmMessage(duplicateInfo), function (confirmed) {
|
||||||
|
var url = confirmed
|
||||||
|
? /*[[@{/login/duplicate/confirm}]]*/ '/login/duplicate/confirm'
|
||||||
|
: /*[[@{/login/duplicate/cancel}]]*/ '/login/duplicate/cancel';
|
||||||
|
$.ajax({
|
||||||
|
url: url,
|
||||||
|
type: 'POST',
|
||||||
|
headers: csrfHeaders(),
|
||||||
|
success: function (res) {
|
||||||
|
if (confirmed) {
|
||||||
|
if (res && res.valid && res.redirect) {
|
||||||
|
window.location.href = res.redirect;
|
||||||
|
} else {
|
||||||
|
customPopups.showAlert((res && res.message) || '로그인 확인이 만료되었습니다. 다시 로그인해주세요.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: function () {
|
||||||
|
if (confirmed) {
|
||||||
|
customPopups.showAlert('로그인 처리 중 오류가 발생했습니다. 다시 로그인해주세요.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
fnInit();
|
fnInit();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
</th:block>
|
</th:block>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
@@ -143,13 +143,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Call Back URL -->
|
|
||||||
<div class="s1-field">
|
|
||||||
<label class="s1-label">Call Back URL</label>
|
|
||||||
<input type="text" id="callbackUrl" name="callbackUrl" th:field="*{callbackUrl}" class="s1-input"
|
|
||||||
placeholder="URL을 입력해 주세요.">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 화이트 리스트 -->
|
<!-- 화이트 리스트 -->
|
||||||
<div class="s1-field">
|
<div class="s1-field">
|
||||||
<label class="s1-label">화이트 리스트 <span class="s1-required">*</span></label>
|
<label class="s1-label">화이트 리스트 <span class="s1-required">*</span></label>
|
||||||
@@ -357,7 +350,6 @@
|
|||||||
form.addEventListener('submit', function (e) {
|
form.addEventListener('submit', function (e) {
|
||||||
const name = document.getElementById('appName').value.trim();
|
const name = document.getElementById('appName').value.trim();
|
||||||
const desc = textarea.value.trim();
|
const desc = textarea.value.trim();
|
||||||
const url = document.getElementById('callbackUrl').value.trim();
|
|
||||||
|
|
||||||
if (!name) {
|
if (!name) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -372,15 +364,6 @@
|
|||||||
textarea.focus();
|
textarea.focus();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (url) {
|
|
||||||
try { new URL(url); } catch (_) {
|
|
||||||
e.preventDefault();
|
|
||||||
customPopups.showAlert('올바른 URL 형식이 아닙니다.\n예: https://example.com/callback');
|
|
||||||
document.getElementById('callbackUrl').focus();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -111,6 +111,38 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script th:if="${error}" th:inline="javascript">
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
customPopups.showAlert(/*[[${error}]]*/ '');
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<script th:inline="javascript">
|
||||||
|
// 반영 직전 2FA: twofaRequired 면 최종 저장 제출을 가로채 2FA 팝업 → 성공 시 실제 제출.
|
||||||
|
// api-selector.js 의 submit 리스너(선택 검증·hidden 동기화)가 먼저 실행된 뒤 동작한다.
|
||||||
|
// "이전" 버튼(btnPrevStep)은 form.submit() 직접 호출이라 submit 이벤트를 타지 않음 → 2FA 미적용.
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
var twofaRequired = /*[[${twofaRequired}]]*/ false;
|
||||||
|
if (!twofaRequired) return;
|
||||||
|
var form = document.getElementById('apiSelectorForm');
|
||||||
|
if (!form) return;
|
||||||
|
|
||||||
|
form.addEventListener('submit', function (e) {
|
||||||
|
// 앞선 리스너가 검증 실패로 막았거나(미선택 등) 이미 취소된 제출이면 개입하지 않는다.
|
||||||
|
if (e.defaultPrevented) return;
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (typeof TwoFactorAuth === 'undefined') { form.submit(); return; }
|
||||||
|
TwoFactorAuth.open({
|
||||||
|
mode: 'stepup',
|
||||||
|
purpose: '/myapikey/modify/step2',
|
||||||
|
// form.submit() 은 submit 이벤트를 재발생시키지 않으므로 그대로 서버로 전송된다.
|
||||||
|
onSuccess: function () { form.submit(); },
|
||||||
|
onCancel: function () { /* 사용자 취소 — step2 유지 */ }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
</th:block>
|
</th:block>
|
||||||
|
|
||||||
<!-- 화면 전체 오버레이/플로팅은 body 직속(pagePopups)으로 렌더 → wrapper transform·overflow 영향 없이 뷰포트 기준 중앙 정렬 -->
|
<!-- 화면 전체 오버레이/플로팅은 body 직속(pagePopups)으로 렌더 → wrapper transform·overflow 영향 없이 뷰포트 기준 중앙 정렬 -->
|
||||||
|
|||||||
@@ -151,7 +151,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="s3-message-wrapper">
|
<div class="s3-message-wrapper">
|
||||||
<h1 class="s3-success-title">앱 수정이 완료되었습니다.</h1>
|
<h1 class="s3-success-title">앱 수정 신청이 완료되었습니다.</h1>
|
||||||
<p class="s3-success-desc">
|
<p class="s3-success-desc">
|
||||||
<span class="s3-highlight">담당자 승인 후 변경 사항이 적용됩니다.</span>
|
<span class="s3-highlight">담당자 승인 후 변경 사항이 적용됩니다.</span>
|
||||||
<br>
|
<br>
|
||||||
|
|||||||
@@ -25,9 +25,13 @@
|
|||||||
<!-- 모든 스타일은 detail-wrap 안에서만 적용 -->
|
<!-- 모든 스타일은 detail-wrap 안에서만 적용 -->
|
||||||
<div class="detail-wrap">
|
<div class="detail-wrap">
|
||||||
<!-- Title Bar (Figma 및 스크린샷 대응) -->
|
<!-- Title Bar (Figma 및 스크린샷 대응) -->
|
||||||
<div class="common-title-bar">
|
<div class="board-header">
|
||||||
<h2 class="common-title" th:text="${appRequest.approval != null and appRequest.approval.approvalStatus != null and appRequest.approval.approvalStatus.toString() == 'APPROVED' ? '인증키 정보' : '신청 상세'}">신청 상세</h2>
|
<h2 class="board-title"
|
||||||
<span class="common-subtitle" th:text="${appRequest.approval != null and appRequest.approval.approvalStatus != null and appRequest.approval.approvalStatus.toString() == 'APPROVED' ? '등록된 앱의 상세 정보를 확인할 수 있습니다.' : '신청하신 API Key의 상세 정보를 확인할 수 있습니다.'}">신청하신 API Key의 상세 정보를 확인할 수 있습니다.</span>
|
th:text="${appRequest.approval != null and appRequest.approval.approvalStatus != null and appRequest.approval.approvalStatus.toString() == 'APPROVED' ? '인증키 정보' : '신청 상세'}">
|
||||||
|
신청 상세</h2>
|
||||||
|
<span class="board-desc"
|
||||||
|
th:text="${appRequest.approval != null and appRequest.approval.approvalStatus != null and appRequest.approval.approvalStatus.toString() == 'APPROVED' ? '등록된 앱의 상세 정보를 확인할 수 있습니다.' : '신청하신 API Key의 상세 정보를 확인할 수 있습니다.'}">신청하신
|
||||||
|
API Key의 상세 정보를 확인할 수 있습니다.</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- App Basic Info Card -->
|
<!-- App Basic Info Card -->
|
||||||
@@ -49,7 +53,7 @@
|
|||||||
<div class="dt-app-status-row" th:if="${appRequest.approval != null}">
|
<div class="dt-app-status-row" th:if="${appRequest.approval != null}">
|
||||||
<span class="dt-status-badge"
|
<span class="dt-status-badge"
|
||||||
th:classappend="${appRequest.approval.approvalStatus.toString() == 'APPROVED' ? 'status-approved' :
|
th:classappend="${appRequest.approval.approvalStatus.toString() == 'APPROVED' ? 'status-approved' :
|
||||||
(appRequest.approval.approvalStatus.toString() == 'REJECTED' ? 'status-rejected' : 'status-pending')}"
|
(appRequest.approval.approvalStatus.toString() == 'REJECTED' or appRequest.approval.approvalStatus.toString() == 'REQUESTED' ? 'status-rejected' : 'status-pending')}"
|
||||||
th:text="${appRequest.approval.approvalStatus.description}">
|
th:text="${appRequest.approval.approvalStatus.description}">
|
||||||
대기중
|
대기중
|
||||||
</span>
|
</span>
|
||||||
@@ -80,7 +84,8 @@
|
|||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none"
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none"
|
||||||
stroke="currentColor" stroke-width="2">
|
stroke="currentColor" stroke-width="2">
|
||||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
|
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
|
||||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
|
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1">
|
||||||
|
</path>
|
||||||
</svg>
|
</svg>
|
||||||
<span>복사</span>
|
<span>복사</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -176,7 +181,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 승인일시 (approvalDate 가 있을 때만) -->
|
<!-- 승인일시 (approvalDate 가 있을 때만) -->
|
||||||
<div class="dt-row" th:if="${appRequest.approval != null and appRequest.approval.approvalDate != null}">
|
<div class="dt-row"
|
||||||
|
th:if="${appRequest.approval != null and appRequest.approval.approvalDate != null}">
|
||||||
<div class="dt-col">
|
<div class="dt-col">
|
||||||
<label class="dt-label">승인일시</label>
|
<label class="dt-label">승인일시</label>
|
||||||
<div class="dt-input-group">
|
<div class="dt-input-group">
|
||||||
@@ -204,11 +210,16 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 내부 결재 진행중: 취소 불가 안내 -->
|
<!-- 내부 결재 진행중: 취소 불가 안내 -->
|
||||||
<p class="dt-cancel-notice" sec:authorize="hasRole('ROLE_API_KEY_REQUEST')"
|
<div class="info-notice-box danger" sec:authorize="hasRole('ROLE_API_KEY_REQUEST')" th:if="${appRequest.approval != null and appRequest.approval.approvalStatus != null and
|
||||||
th:if="${appRequest.approval != null and appRequest.approval.approvalStatus != null and
|
|
||||||
appRequest.approval.approvalStatus.toString() == 'PROCESSING'}">
|
appRequest.approval.approvalStatus.toString() == 'PROCESSING'}">
|
||||||
내부 결재가 진행 중이라 신청을 취소할 수 없습니다. 취소가 필요한 경우 관리자에게 문의해 주세요.
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#f4253c"
|
||||||
</p>
|
stroke-width="2">
|
||||||
|
<circle cx="12" cy="12" r="10"></circle>
|
||||||
|
<line x1="12" y1="8" x2="12" y2="12"></line>
|
||||||
|
<line x1="12" y1="16" x2="12.01" y2="16"></line>
|
||||||
|
</svg>
|
||||||
|
<p>내부 결재가 진행 중이라 신청을 취소할 수 없습니다. 취소가 필요한 경우 관리자에게 문의해 주세요.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Bottom Navigation Actions -->
|
<!-- Bottom Navigation Actions -->
|
||||||
<div class="dt-actions">
|
<div class="dt-actions">
|
||||||
|
|||||||
@@ -1,125 +1,142 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html xmlns:th="http://www.thymeleaf.org"
|
<html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
|
||||||
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
|
xmlns:sec="http://www.thymeleaf.org/extras/spring-security" layout:decorate="~{layout/djbank_title_layout}">
|
||||||
xmlns:sec="http://www.thymeleaf.org/extras/spring-security"
|
|
||||||
layout:decorate="~{layout/djbank_title_layout}">
|
|
||||||
<head>
|
<head>
|
||||||
<title>API 키 상세</title>
|
<title>API 키 상세</title>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<section layout:fragment="title">
|
<section layout:fragment="title">
|
||||||
<div class="page-title-banner">
|
<div class="page-title-banner">
|
||||||
<img th:src="@{/img/img_title_bg.png}" class="title-image">
|
<img th:src="@{/img/img_title_bg.png}" class="title-image">
|
||||||
<h1>앱관리</h1>
|
<h1>앱관리</h1>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<th:block layout:fragment="contentFragment">
|
<th:block layout:fragment="contentFragment">
|
||||||
<div class="signup-guide-v2 figma-register-wrapper">
|
<div class="signup-guide-v2 figma-register-wrapper">
|
||||||
<div class="service-main app-management-layout">
|
<div class="service-main app-management-layout">
|
||||||
|
|
||||||
<!-- Left Sidebar (Service Sidebar) -->
|
<!-- Left Sidebar (Service Sidebar) -->
|
||||||
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('apiKey')}"></th:block>
|
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('apiKey')}"></th:block>
|
||||||
|
|
||||||
<!-- Content Area -->
|
<!-- Content Area -->
|
||||||
<div class="app-management-content">
|
<div class="app-management-content">
|
||||||
<div class="detail-wrap">
|
<div class="detail-wrap">
|
||||||
<!-- Title Bar -->
|
<!-- Title Bar -->
|
||||||
<div class="common-title-bar">
|
<div class="board-header">
|
||||||
<h2 class="common-title">인증키 정보</h2>
|
<h2 class="board-title">인증키 정보</h2>
|
||||||
<span class="common-subtitle">등록된 앱의 상세 정보를 확인할 수 있습니다.</span>
|
<span class="board-desc">등록된 앱의 상세 정보를 확인할 수 있습니다.</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- App Basic Info Card -->
|
<!-- App Basic Info Card -->
|
||||||
<div class="dt-app-card">
|
<div class="dt-app-card">
|
||||||
<div class="dt-app-icon-box">
|
<div class="dt-app-icon-box">
|
||||||
<img th:if="${apiKey.appIconFileId != null}" th:src="@{/file/download(fileSn=1,fileId=${apiKey.appIconFileId})}" alt="앱 아이콘">
|
<img th:if="${apiKey.appIconFileId != null}"
|
||||||
<svg th:unless="${apiKey.appIconFileId != null}" width="46" height="46" viewBox="0 0 24 24" fill="none" stroke="#64748b" stroke-width="1.5">
|
th:src="@{/file/download(fileSn=1,fileId=${apiKey.appIconFileId})}" alt="앱 아이콘">
|
||||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
|
<svg th:unless="${apiKey.appIconFileId != null}" width="46" height="46" viewBox="0 0 24 24" fill="none"
|
||||||
<circle cx="8.5" cy="8.5" r="1.5"></circle>
|
stroke="#64748b" stroke-width="1.5">
|
||||||
<polyline points="21 15 16 10 5 21"></polyline>
|
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
|
||||||
</svg>
|
<circle cx="8.5" cy="8.5" r="1.5"></circle>
|
||||||
</div>
|
<polyline points="21 15 16 10 5 21"></polyline>
|
||||||
<div class="dt-app-info">
|
</svg>
|
||||||
<div class="dt-app-status-row">
|
</div>
|
||||||
<span th:classappend="${apiKey.appstatus == '1' ? 'status-approved' : 'status-inactive'}" class="dt-status-badge" th:text="${apiKey.appstatus == '1' ? '승인' : '비활성화'}">승인</span>
|
<div class="dt-app-info">
|
||||||
</div>
|
<div class="dt-app-status-row">
|
||||||
<h3 class="dt-app-name" th:text="${apiKey.clientname}">앱 이름</h3>
|
<span th:classappend="${apiKey.appstatus == '1' ? 'status-approved' : 'status-inactive'}"
|
||||||
<p class="dt-app-desc" th:text="${apiKey.appDescription != null ? apiKey.appDescription : '앱 설명이 없습니다.'}">앱 설명이 표시됩니다.</p>
|
class="dt-status-badge" th:text="${apiKey.appstatus == '1' ? '승인' : '비활성화'}">승인</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<h3 class="dt-app-name" th:text="${apiKey.clientname}">앱 이름</h3>
|
||||||
|
<p class="dt-app-desc"
|
||||||
|
th:text="${apiKey.appDescription != null ? apiKey.appDescription : '앱 설명이 없습니다.'}">앱 설명이 표시됩니다.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Credential Info Section Card -->
|
<!-- Credential Info Section Card -->
|
||||||
<div class="dt-info-container">
|
<div class="dt-info-container">
|
||||||
|
|
||||||
<!-- Client ID -->
|
<!-- Client ID -->
|
||||||
<div class="dt-row">
|
<div class="dt-row">
|
||||||
<div class="dt-col">
|
<div class="dt-col">
|
||||||
<label class="dt-label">Client ID</label>
|
<label class="dt-label">Client ID</label>
|
||||||
<div class="dt-input-group">
|
<div class="dt-input-group">
|
||||||
<div class="dt-input-box" th:text="${apiKey.clientid}">K00000001</div>
|
<div class="dt-input-box" th:text="${apiKey.clientid}">K00000001</div>
|
||||||
<button type="button" class="dt-btn-copy" th:data-secret="${apiKey.clientid}" onclick="copyToClipboardFromButton(this)">
|
<button type="button" class="dt-btn-copy" th:data-secret="${apiKey.clientid}"
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
onclick="copyToClipboardFromButton(this)">
|
||||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
|
stroke-width="2">
|
||||||
</svg>
|
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
|
||||||
<span>복사</span>
|
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
|
||||||
</button>
|
</svg>
|
||||||
</div>
|
<span>복사</span>
|
||||||
</div>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Client Secret -->
|
<!-- Client Secret -->
|
||||||
<div class="dt-row">
|
<div class="dt-row">
|
||||||
<div class="dt-col">
|
<div class="dt-col">
|
||||||
<label class="dt-label">
|
<label class="dt-label">
|
||||||
Client Secret
|
Client Secret
|
||||||
<button type="button" class="btn-guide-info" onclick="showLostKeyGuide()" title="비밀정보 안내" aria-label="비밀정보 안내"
|
<button type="button" class="btn-guide-info" onclick="showLostKeyGuide()" title="비밀정보 안내"
|
||||||
style="margin-left:6px;width:18px;height:18px;border-radius:50%;border:1px solid #bbb;background:#f5f5f5;color:#666;font-size:12px;line-height:1;cursor:pointer;padding:0;vertical-align:middle;">?</button>
|
aria-label="비밀정보 안내"
|
||||||
</label>
|
style="margin-left:6px;width:18px;height:18px;border-radius:50%;border:1px solid #bbb;background:#f5f5f5;color:#666;font-size:12px;line-height:1;cursor:pointer;padding:0;vertical-align:middle;">?</button>
|
||||||
|
</label>
|
||||||
|
|
||||||
<!-- secret이 아직 DB에 존재: 최초 1회 조회 가능 -->
|
<!-- secret이 아직 DB에 존재: 최초 1회 조회 가능 -->
|
||||||
<th:block th:if="${secretAvailable}">
|
<th:block th:if="${secretAvailable}">
|
||||||
<!-- View Button (shown initially) -->
|
<!-- View Button (shown initially) -->
|
||||||
<div class="dt-input-group" id="hiddenSecretBox">
|
<div class="dt-input-group" id="hiddenSecretBox">
|
||||||
<button type="button" style="width: 100%; border-radius: 10px; background-color: #2a69de; color: #fff; height: 47px; display: flex; justify-content: center; align-items: center; border: none; font-size: 16px; font-weight: 500; cursor: pointer; transition: background-color 0.2s;" onmouseover="this.style.backgroundColor='#1d56c0'" onmouseout="this.style.backgroundColor='#2a69de'" onclick="showPasswordPrompt()">
|
<button type="button"
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right: 8px;">
|
style="width: 100%; border-radius: 10px; background-color: #2a69de; color: #fff; height: 47px; display: flex; justify-content: center; align-items: center; border: none; font-size: 16px; font-weight: 500; cursor: pointer; transition: background-color 0.2s;"
|
||||||
<circle cx="11" cy="11" r="8"></circle>
|
onmouseover="this.style.backgroundColor='#1d56c0'"
|
||||||
<path d="m21 21-4.35-4.35"></path>
|
onmouseout="this.style.backgroundColor='#2a69de'" onclick="showPasswordPrompt()">
|
||||||
</svg>
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none"
|
||||||
<span>조회</span>
|
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
||||||
</button>
|
style="margin-right: 8px;">
|
||||||
</div>
|
<circle cx="11" cy="11" r="8"></circle>
|
||||||
|
<path d="m21 21-4.35-4.35"></path>
|
||||||
|
</svg>
|
||||||
|
<span>조회</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Actual Secret (hidden initially, 값은 서버 응답으로 JS가 주입) -->
|
<!-- Actual Secret (hidden initially, 값은 서버 응답으로 JS가 주입) -->
|
||||||
<div id="revealedSecretBox" style="display: none; width: 100%;">
|
<div id="revealedSecretBox" style="display: none; width: 100%;">
|
||||||
<div class="dt-input-group">
|
<div class="dt-input-group">
|
||||||
<div class="dt-input-box" id="revealedSecretValue" style="flex: 1;"></div>
|
<div class="dt-input-box" id="revealedSecretValue" style="flex: 1;"></div>
|
||||||
<button type="button" class="dt-btn-copy" id="revealedSecretCopyBtn" onclick="copyToClipboardFromButton(this)">
|
<button type="button" class="dt-btn-copy" id="revealedSecretCopyBtn"
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
onclick="copyToClipboardFromButton(this)">
|
||||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
|
stroke-width="2">
|
||||||
</svg>
|
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
|
||||||
<span>복사</span>
|
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
|
||||||
</button>
|
</svg>
|
||||||
</div>
|
<span>복사</span>
|
||||||
<p class="secret-warning" style="margin:10px 0 0;padding:10px 12px;background:#fdecea;border:1px solid #f5c6c2;border-radius:6px;color:#c0392b;font-size:13px;line-height:1.6;">
|
</button>
|
||||||
⚠ 이 값은 <strong>지금 한 번만</strong> 표시되며, 이후에는 다시 조회할 수 없습니다.<br>
|
</div>
|
||||||
반드시 <strong>안전한 곳에 복사하여 보관</strong>해 주세요.
|
<p class="secret-warning"
|
||||||
</p>
|
style="margin:10px 0 0;padding:10px 12px;background:#fdecea;border:1px solid #f5c6c2;border-radius:6px;color:#c0392b;font-size:13px;line-height:1.6;">
|
||||||
</div>
|
⚠ 이 값은 <strong>지금 한 번만</strong> 표시되며, 이후에는 다시 조회할 수 없습니다.<br>
|
||||||
</th:block>
|
반드시 <strong>안전한 곳에 복사하여 보관</strong>해 주세요.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</th:block>
|
||||||
|
|
||||||
<!-- secret이 이미 노출·삭제됨 -->
|
<!-- secret이 이미 노출·삭제됨 -->
|
||||||
<div th:unless="${secretAvailable}" class="secret-revealed-notice" style="display:flex;align-items:center;gap:12px;">
|
<div th:unless="${secretAvailable}" class="secret-revealed-notice"
|
||||||
<span class="secret-revealed-text" style="color:#888;font-size:14px;">이미 1회 노출되어 삭제된 인증정보입니다.</span>
|
style="display:flex;align-items:center;gap:12px;">
|
||||||
<button type="button" class="btn-guide-link" onclick="showLostKeyGuide()"
|
<span class="secret-revealed-text" style="color:#888;font-size:14px;">이미 1회 노출되어 삭제된 인증정보입니다.</span>
|
||||||
style="background:none;border:none;color:#2b6cb0;text-decoration:underline;cursor:pointer;font-size:13px;padding:0;">분실 시 안내</button>
|
<button type="button" class="btn-guide-link" onclick="showLostKeyGuide()"
|
||||||
</div>
|
style="background:none;border:none;color:#2b6cb0;text-decoration:underline;cursor:pointer;font-size:13px;padding:0;">분실
|
||||||
</div>
|
시 안내</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Callback URL (기능본에서 비활성 처리됨 — 렌더링되지 않음) -->
|
<!-- Callback URL (기능본에서 비활성 처리됨 — 렌더링되지 않음) -->
|
||||||
<!--/*
|
<!--/*
|
||||||
<div class="dt-row">
|
<div class="dt-row">
|
||||||
<div class="dt-col">
|
<div class="dt-col">
|
||||||
<label class="dt-label">Callback URL</label>
|
<label class="dt-label">Callback URL</label>
|
||||||
@@ -130,219 +147,215 @@
|
|||||||
</div>
|
</div>
|
||||||
*/-->
|
*/-->
|
||||||
|
|
||||||
<!-- 화이트 리스트 -->
|
<!-- 화이트 리스트 -->
|
||||||
<div class="dt-row">
|
<div class="dt-row">
|
||||||
<div class="dt-col">
|
<div class="dt-col">
|
||||||
<label class="dt-label">화이트 리스트</label>
|
<label class="dt-label">화이트 리스트</label>
|
||||||
<div class="dt-input-group">
|
<div class="dt-input-group">
|
||||||
<div class="dt-input-box" th:text="${apiKey.allowedips != null ? apiKey.allowedips : '설정되지 않음'}">설정되지 않음</div>
|
<div class="dt-input-box" th:text="${apiKey.allowedips != null ? apiKey.allowedips : '설정되지 않음'}">
|
||||||
</div>
|
설정되지 않음</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- API 목록 -->
|
<!-- API 목록 -->
|
||||||
<div class="dt-row">
|
<div class="dt-row">
|
||||||
<div class="dt-col">
|
<div class="dt-col">
|
||||||
<label class="dt-label">API 목록</label>
|
<label class="dt-label">API 목록</label>
|
||||||
<div class="dt-api-list-box">
|
<div class="dt-api-list-box">
|
||||||
<div class="dt-api-item" th:each="api : ${apiKey.apiList}">
|
<div class="dt-api-item" th:each="api : ${apiKey.apiList}">
|
||||||
<span class="dt-api-name" th:text="${api.apiDesc}">추가된 API 1</span>
|
<span class="dt-api-name" th:text="${api.apiDesc}">추가된 API 1</span>
|
||||||
<span class="dt-api-status-badge">승인</span>
|
<span class="dt-api-status-badge">승인</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="dt-empty-message" th:if="${apiKey.apiList == null or apiKey.apiList.isEmpty()}">
|
<div class="dt-empty-message" th:if="${apiKey.apiList == null or apiKey.apiList.isEmpty()}">
|
||||||
등록된 API가 없습니다.
|
등록된 API가 없습니다.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 최종 수정일자 -->
|
<!-- 최종 수정일자 -->
|
||||||
<div class="dt-row">
|
<div class="dt-row">
|
||||||
<div class="dt-col">
|
<div class="dt-col">
|
||||||
<label class="dt-label">최종 수정일자</label>
|
<label class="dt-label">최종 수정일자</label>
|
||||||
<div class="dt-input-group">
|
<div class="dt-input-group">
|
||||||
<div class="dt-input-box" th:text="${#temporals.format(apiKey.modifiedon, 'yyyy.MM.dd')}">2025.11.22</div>
|
<div class="dt-input-box" th:text="${#temporals.format(apiKey.modifiedon, 'yyyy.MM.dd')}">2025.11.22
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Bottom Navigation Actions -->
|
<!-- Bottom Navigation Actions -->
|
||||||
<div class="dt-actions" style="justify-content: flex-end; gap: 11px; margin-top: 30px;">
|
<div class="dt-actions" style="justify-content: flex-end; gap: 11px; margin-top: 30px;">
|
||||||
<a th:href="@{/myapikey}" class="dt-btn-gray">목록</a>
|
<a th:href="@{/myapikey}" class="dt-btn-gray">목록</a>
|
||||||
<button type="button" sec:authorize="hasRole('ROLE_API_KEY_REQUEST')" class="dt-btn-red" th:data-client-id="${apiKey.clientid}" onclick="deleteApiKeyFromButton(this)">인증키 삭제</button>
|
<button type="button" sec:authorize="hasRole('ROLE_API_KEY_REQUEST')" class="dt-btn-red"
|
||||||
<a sec:authorize="hasRole('ROLE_API_KEY_REQUEST')" class="dt-btn-blue" th:href="@{/myapikey/modify/step1(clientId=${apiKey.clientid})}">수정</a>
|
th:data-client-id="${apiKey.clientid}" onclick="deleteApiKeyFromButton(this)">인증키 삭제</button>
|
||||||
</div>
|
<a sec:authorize="hasRole('ROLE_API_KEY_REQUEST')" class="dt-btn-blue"
|
||||||
|
th:href="@{/myapikey/modify/step1(clientId=${apiKey.clientid})}">수정</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div><!-- /detail-wrap -->
|
</div><!-- /detail-wrap -->
|
||||||
</div><!-- /app-management-content -->
|
</div><!-- /app-management-content -->
|
||||||
</div><!-- /service-main -->
|
</div><!-- /service-main -->
|
||||||
</div><!-- /signup-guide-v2 -->
|
</div><!-- /signup-guide-v2 -->
|
||||||
|
|
||||||
</th:block>
|
</th:block>
|
||||||
|
|
||||||
<th:block layout:fragment="contentScript">
|
<th:block layout:fragment="contentScript">
|
||||||
<script th:inline="javascript">
|
<script th:inline="javascript">
|
||||||
/*<![CDATA[*/
|
/*<![CDATA[*/
|
||||||
|
|
||||||
var CREDENTIAL_CLIENT_ID = /*[[${apiKey.clientid}]]*/ '';
|
var CREDENTIAL_CLIENT_ID = /*[[${apiKey.clientid}]]*/ '';
|
||||||
|
|
||||||
// 비밀정보 안내(분실 시 조치) 가이드 팝업
|
// 비밀정보 안내(분실 시 조치) 가이드 팝업
|
||||||
function showLostKeyGuide() {
|
function showLostKeyGuide() {
|
||||||
customPopups.showAlert(
|
customPopups.showAlert(
|
||||||
'Client Secret은 보안을 위해 <strong>최초 1회만 조회</strong>할 수 있으며,<br>' +
|
'Client Secret은 보안을 위해 <strong>최초 1회만 조회</strong>할 수 있으며,<br>' +
|
||||||
'조회하는 즉시 개발자포탈에서 <strong>영구 삭제</strong>됩니다.<br><br>' +
|
'조회하는 즉시 개발자포탈에서 <strong>영구 삭제</strong>됩니다.<br><br>' +
|
||||||
'값을 분실하셨다면 복구가 불가능하므로,<br>' +
|
'값을 분실하셨다면 복구가 불가능하므로,<br>' +
|
||||||
'<strong>인증키를 새로 신청</strong>하여 발급받아 주세요.'
|
'<strong>인증키를 새로 신청</strong>하여 발급받아 주세요.'
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
// step-up 2FA 응답(401 + stepUpRequired) 판별 및 처리 헬퍼
|
|
||||||
function isStepUpRequired(jqXHR) {
|
|
||||||
return jqXHR && jqXHR.status === 401 && jqXHR.responseJSON && jqXHR.responseJSON.stepUpRequired;
|
|
||||||
}
|
|
||||||
function requireStepUp(purpose, retry) {
|
|
||||||
if (typeof TwoFactorAuth === 'undefined') {
|
|
||||||
customPopups.showAlert('추가 인증이 필요합니다. 다시 시도해주세요.');
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
TwoFactorAuth.open({
|
|
||||||
mode: 'stepup',
|
|
||||||
purpose: purpose,
|
|
||||||
onSuccess: function() { retry(); },
|
|
||||||
onCancel: function() { /* 취소: 원 작업 중단 */ }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show password prompt for viewing client secret (최초 1회 노출 + 서버측 물리 삭제)
|
// Show password prompt for viewing client secret (최초 1회 노출 + 서버측 물리 삭제)
|
||||||
function showPasswordPrompt() {
|
function showPasswordPrompt() {
|
||||||
customPopups.showPasswordInput({
|
customPopups.showPasswordInput({
|
||||||
title: 'Client Secret 조회',
|
title: 'Client Secret 조회',
|
||||||
message: '보안을 위해 비밀번호를 입력해주세요.<br>조회 즉시 값은 영구 삭제됩니다.',
|
message: '보안을 위해 비밀번호를 입력해주세요.<br>조회 즉시 값은 영구 삭제됩니다.',
|
||||||
onConfirm: function(password) {
|
onConfirm: function (password) {
|
||||||
doRevealSecret(password);
|
$.ajax({
|
||||||
},
|
url: /*[[@{/myapikey/credential/reveal-secret}]]*/ '/myapikey/credential/reveal-secret',
|
||||||
onCancel: function() {
|
type: 'POST',
|
||||||
// User cancelled - do nothing
|
contentType: 'application/json',
|
||||||
}
|
data: JSON.stringify({ clientId: CREDENTIAL_CLIENT_ID, password: password }),
|
||||||
});
|
headers: {
|
||||||
}
|
'X-XSRF-TOKEN': /*[[${_csrf.token}]]*/ 'token'
|
||||||
|
}
|
||||||
|
}).done(function (response) {
|
||||||
|
if (response.success) {
|
||||||
|
customPopups.hidePasswordInput();
|
||||||
|
|
||||||
// Client Secret 조회 요청 (step-up 필요 시 2FA 후 동일 요청 재시도)
|
// 서버가 반환한 secret을 화면에 1회 주입
|
||||||
function doRevealSecret(password) {
|
$('#revealedSecretValue').text(response.secret);
|
||||||
$.ajax({
|
$('#revealedSecretCopyBtn').attr('data-secret', response.secret).data('secret', response.secret);
|
||||||
url: /*[[@{/myapikey/credential/reveal-secret}]]*/ '/myapikey/credential/reveal-secret',
|
|
||||||
type: 'POST',
|
|
||||||
contentType: 'application/json',
|
|
||||||
data: JSON.stringify({ clientId: CREDENTIAL_CLIENT_ID, password: password }),
|
|
||||||
headers: {
|
|
||||||
'X-XSRF-TOKEN': /*[[${_csrf.token}]]*/ 'token'
|
|
||||||
}
|
|
||||||
}).done(function(response) {
|
|
||||||
if (response.success) {
|
|
||||||
customPopups.hidePasswordInput();
|
|
||||||
|
|
||||||
// 서버가 반환한 secret을 화면에 1회 주입
|
$('#hiddenSecretBox').hide();
|
||||||
$('#revealedSecretValue').text(response.secret);
|
$('#revealedSecretBox').fadeIn(300);
|
||||||
$('#revealedSecretCopyBtn').attr('data-secret', response.secret).data('secret', response.secret);
|
} else if (response.alreadyRevealed) {
|
||||||
|
customPopups.hidePasswordInput();
|
||||||
|
showLostKeyGuide();
|
||||||
|
} else {
|
||||||
|
customPopups.showPasswordError(response.message || '비밀번호가 일치하지 않습니다.');
|
||||||
|
}
|
||||||
|
}).fail(function () {
|
||||||
|
customPopups.showPasswordError('오류가 발생했습니다. 다시 시도해주세요.');
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onCancel: function () {
|
||||||
|
// User cancelled - do nothing
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
$('#hiddenSecretBox').hide();
|
// Copy to clipboard function - called from button with data-secret attribute
|
||||||
$('#revealedSecretBox').fadeIn(300);
|
function copyToClipboardFromButton(button) {
|
||||||
} else if (response.alreadyRevealed) {
|
var text = $(button).data('secret');
|
||||||
customPopups.hidePasswordInput();
|
|
||||||
showLostKeyGuide();
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||||
|
navigator.clipboard.writeText(text).then(function () {
|
||||||
|
alert('클립보드에 복사되었습니다.');
|
||||||
|
}).catch(function (err) {
|
||||||
|
console.error('Failed to copy:', err);
|
||||||
|
fallbackCopy(text);
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
customPopups.showPasswordError(response.message || '비밀번호가 일치하지 않습니다.');
|
|
||||||
}
|
|
||||||
}).fail(function(jqXHR) {
|
|
||||||
if (isStepUpRequired(jqXHR)) {
|
|
||||||
customPopups.hidePasswordInput();
|
|
||||||
requireStepUp('/myapikey/credential/reveal-secret', function() { doRevealSecret(password); });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
customPopups.showPasswordError('오류가 발생했습니다. 다시 시도해주세요.');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Copy to clipboard function - called from button with data-secret attribute
|
|
||||||
function copyToClipboardFromButton(button) {
|
|
||||||
var text = $(button).data('secret');
|
|
||||||
|
|
||||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
||||||
navigator.clipboard.writeText(text).then(function() {
|
|
||||||
alert('클립보드에 복사되었습니다.');
|
|
||||||
}).catch(function(err) {
|
|
||||||
console.error('Failed to copy:', err);
|
|
||||||
fallbackCopy(text);
|
fallbackCopy(text);
|
||||||
});
|
}
|
||||||
} else {
|
|
||||||
fallbackCopy(text);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
function fallbackCopy(text) {
|
function fallbackCopy(text) {
|
||||||
const textArea = document.createElement('textarea');
|
const textArea = document.createElement('textarea');
|
||||||
textArea.value = text;
|
textArea.value = text;
|
||||||
textArea.style.position = 'fixed';
|
textArea.style.position = 'fixed';
|
||||||
textArea.style.left = '-999999px';
|
textArea.style.left = '-999999px';
|
||||||
document.body.appendChild(textArea);
|
document.body.appendChild(textArea);
|
||||||
textArea.select();
|
textArea.select();
|
||||||
try {
|
try {
|
||||||
document.execCommand('copy');
|
document.execCommand('copy');
|
||||||
alert('클립보드에 복사되었습니다.');
|
alert('클립보드에 복사되었습니다.');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert('복사에 실패했습니다.');
|
alert('복사에 실패했습니다.');
|
||||||
|
}
|
||||||
|
document.body.removeChild(textArea);
|
||||||
}
|
}
|
||||||
document.body.removeChild(textArea);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete API Key function - called from button with data-client-id attribute
|
// Delete API Key function - called from button with data-client-id attribute
|
||||||
function deleteApiKeyFromButton(button) {
|
function deleteApiKeyFromButton(button) {
|
||||||
var clientId = $(button).data('client-id');
|
var clientId = $(button).data('client-id');
|
||||||
|
|
||||||
customPopups.showConfirm('정말로 이 인증키를 삭제하시겠습니까?', function(confirmed) {
|
customPopups.showConfirm('정말로 이 인증키를 삭제하시겠습니까?', function (confirmed) {
|
||||||
if (!confirmed) {
|
if (!confirmed) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
doDeleteApiKey(clientId);
|
doDeleteApiKey(clientId);
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 인증키 삭제 요청 (step-up 필요 시 2FA 후 동일 요청 재시도)
|
|
||||||
function doDeleteApiKey(clientId) {
|
|
||||||
$('.loading-overlay').show();
|
|
||||||
|
|
||||||
$.ajax({
|
|
||||||
url: '/myapikey/api_key_delete',
|
|
||||||
type: 'POST',
|
|
||||||
contentType: 'application/json',
|
|
||||||
data: JSON.stringify({ clientId: clientId }),
|
|
||||||
headers: {
|
|
||||||
'X-XSRF-TOKEN': /*[[${_csrf.token}]]*/ 'token'
|
|
||||||
}
|
|
||||||
}).done(function(response) {
|
|
||||||
if (response && response.success === false) {
|
|
||||||
customPopups.showAlert(response.msg || 'API 삭제에 실패했습니다.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
customPopups.showAlert(response.msg || 'API Key가 삭제되었습니다.', function() {
|
|
||||||
window.location.href = /*[[@{/myapikey}]]*/ '/myapikey';
|
|
||||||
});
|
});
|
||||||
}).fail(function(jqXHR, textStatus, errorThrown) {
|
}
|
||||||
if (isStepUpRequired(jqXHR)) {
|
|
||||||
requireStepUp('/myapikey/api_key_delete', function() { doDeleteApiKey(clientId); });
|
// 인증키 삭제 요청 (step-up 필요 시 2FA 후 동일 요청 재시도)
|
||||||
|
function doDeleteApiKey(clientId) {
|
||||||
|
$('.loading-overlay').show();
|
||||||
|
|
||||||
|
$.ajax({
|
||||||
|
url: /*[[@{/myapikey/api_key_delete}]]*/ '/myapikey/api_key_delete',
|
||||||
|
type: 'POST',
|
||||||
|
contentType: 'application/json',
|
||||||
|
data: JSON.stringify({ clientId: clientId }),
|
||||||
|
headers: {
|
||||||
|
'X-XSRF-TOKEN': /*[[${_csrf.token}]]*/ 'token'
|
||||||
|
}
|
||||||
|
}).done(function (response) {
|
||||||
|
if (response && response.success === false) {
|
||||||
|
customPopups.showAlert(response.msg || 'API 삭제에 실패했습니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
customPopups.showAlert(response.msg || 'API Key가 삭제되었습니다.', function () {
|
||||||
|
window.location.href = /*[[@{/myapikey}]]*/ '/myapikey';
|
||||||
|
});
|
||||||
|
}).fail(function (jqXHR, textStatus, errorThrown) {
|
||||||
|
if (isStepUpRequired(jqXHR)) {
|
||||||
|
requireStepUp('/myapikey/api_key_delete', function () { doDeleteApiKey(clientId); });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
customPopups.showAlert('API 삭제 요청 중 오류가 발생했습니다: ' + errorThrown);
|
||||||
|
}).always(function () {
|
||||||
|
$('.loading-overlay').hide();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// step-up 2FA 응답(401 + stepUpRequired) 판별 및 처리 헬퍼
|
||||||
|
function isStepUpRequired(jqXHR) {
|
||||||
|
return jqXHR && jqXHR.status === 401 && jqXHR.responseJSON && jqXHR.responseJSON.stepUpRequired;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireStepUp(purpose, retry) {
|
||||||
|
if (typeof TwoFactorAuth === 'undefined') {
|
||||||
|
customPopups.showAlert('추가 인증이 필요합니다. 다시 시도해주세요.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
customPopups.showAlert('API 삭제 요청 중 오류가 발생했습니다: ' + errorThrown);
|
TwoFactorAuth.open({
|
||||||
}).always(function() {
|
mode: 'stepup',
|
||||||
$('.loading-overlay').hide();
|
purpose: purpose,
|
||||||
});
|
onSuccess: function () { retry(); },
|
||||||
}
|
onCancel: function () { /* 취소: 원 작업 중단 */ }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/*]]>*/
|
/*]]>*/
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
</th:block>
|
</th:block>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
@@ -29,7 +29,8 @@
|
|||||||
<path d="M12 16v-4"></path>
|
<path d="M12 16v-4"></path>
|
||||||
<path d="M12 8h.01"></path>
|
<path d="M12 8h.01"></path>
|
||||||
</svg>
|
</svg>
|
||||||
<p>소중한 계정 보호를 위해 비밀번호를 변경해 주세요!</p>
|
<p>소중한 계정 보호를 위해 비밀번호를 변경해 주세요!<br>
|
||||||
|
비밀번호 변경이 완료되면 자동으로 로그아웃되며, 새 비밀번호로 다시 로그인해야 합니다.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
|
|||||||
@@ -9,8 +9,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section layout:fragment="contentFragment">
|
<section layout:fragment="contentFragment">
|
||||||
<div class="service-main app-management-layout">
|
<div class="service-main container" style="padding-top: 70px; padding-bottom:100px;">
|
||||||
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('apiKey')}"></th:block>
|
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('profile')}"></th:block>
|
||||||
<div class="app-management-content">
|
<div class="app-management-content">
|
||||||
<div class="corp-manager-wrapper">
|
<div class="corp-manager-wrapper">
|
||||||
<h2 class="page-outer-title">법인 기본 정보</h2>
|
<h2 class="page-outer-title">법인 기본 정보</h2>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
|||||||
<!-- views/fragment/popup/withdrawalPopup.html -->
|
<!-- views/fragment/popup/withdrawalPopup.html -->
|
||||||
<div th:fragment="withdrawalPopup" id="withdrawalPopup" style="display: none;">
|
<div th:fragment="withdrawalPopup" id="withdrawalPopup" style="display: none;"
|
||||||
|
th:attr="data-twofa-required=${withdrawTwofaRequired}">
|
||||||
<!-- Modal Backdrop -->
|
<!-- Modal Backdrop -->
|
||||||
<div class="modal-backdrop" id="withdrawalModalBackdrop"></div>
|
<div class="modal-backdrop" id="withdrawalModalBackdrop"></div>
|
||||||
|
|
||||||
@@ -24,15 +25,25 @@
|
|||||||
<li>회원 탈퇴는 API Portal 사이트의 회원 탈퇴입니다.</li>
|
<li>회원 탈퇴는 API Portal 사이트의 회원 탈퇴입니다.</li>
|
||||||
<li>사용중인 API 서비스의 중지는 제휴 담당자를 통해 문의 주시기 바랍니다.</li>
|
<li>사용중인 API 서비스의 중지는 제휴 담당자를 통해 문의 주시기 바랍니다.</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
<div style="margin-top: 16px;">
|
||||||
|
<label for="withdrawalReasonInput" style="display: block; margin-bottom: 6px; font-size: 14px; font-weight: 600; color: #334155;">
|
||||||
|
탈퇴 사유 <span style="color: #d63a3a;">(필수)</span>
|
||||||
|
</label>
|
||||||
|
<textarea id="withdrawalReasonInput" maxlength="200" rows="3"
|
||||||
|
placeholder="탈퇴 사유를 입력해 주세요. (최대 200자)"
|
||||||
|
style="width: 100%; box-sizing: border-box; padding: 10px 12px; border: 1px solid #CBD5E1; border-radius: 6px; font-size: 14px; line-height: 1.5; resize: vertical;"></textarea>
|
||||||
|
<p id="withdrawalReasonError" style="display: none; margin: 6px 0 0 0; font-size: 13px; color: #d63a3a;">탈퇴 사유를 입력해 주세요.</p>
|
||||||
|
</div>
|
||||||
<form id="withdrawalForm" th:action="@{/withdraw}" method="POST" style="display:none;">
|
<form id="withdrawalForm" th:action="@{/withdraw}" method="POST" style="display:none;">
|
||||||
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/>
|
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/>
|
||||||
|
<input type="hidden" name="withdrawReason" id="withdrawalReasonHidden"/>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Modal Footer -->
|
<!-- Modal Footer -->
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn btn-secondary" id="withdrawalPopupCancelButton">취소</button>
|
<button type="button" class="btn btn-secondary" id="withdrawalPopupCancelButton">취소</button>
|
||||||
<button type="button" class="btn btn-primary" id="withdrawalPopupConfirmButton">탈퇴신청</button>
|
<button type="button" class="btn btn-primary" id="withdrawalPopupConfirmButton">탈퇴하기</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user