6 Commits

Author SHA1 Message Date
Rinjae 929980c089 중복 로그인 확인 및 처리 로직 추가:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 동시 접속 확인 API(DuplicateLoginController) 및 서비스 구현
- 중복 세션 확인 후 로그인 확정/취소 처리 로직 추가
- 2FA 흐름과 연계된 프론트엔드 수정 및 UI 개선
2026-07-28 17:00:10 +09:00
Rinjae d4d5d66727 앱 수정 최종 반영(2FA) 로직 추가:
- 최종 저장 시 2FA 통과권 요구 로직 추가 및 중복 인증 방지
- Step2 2FA 팝업 및 프론트엔드 2FA 검증 로직 반영
- 불필요한 CallBack URL 필드 제거 및 UI 문구 수정
2026-07-28 16:38:44 +09:00
Rinjae d3e6249a41 앱 수정 최종 반영(2FA) 로직 추가:
- 최종 저장 시 2FA 통과권 요구 로직 추가 및 중복 인증 방지
- Step2 2FA 팝업 및 프론트엔드 2FA 검증 로직 반영
- 불필요한 CallBack URL 필드 제거 및 UI 문구 수정
2026-07-28 16:38:37 +09:00
Rinjae 68f8c07fc0 Merge branch 'master' into design 2026-07-28 16:07:51 +09:00
hong 80740a9543 merge 충돌 해결 2026-07-28 14:35:21 +09:00
hong cdd68f48f0 fix: 마이페이지 이용 통계 , 내정보 관리 , 비밀전호 변경 & 로그인 반응형 퍼블리싱 2026-07-28 14:30:03 +09:00
37 changed files with 2426 additions and 1487 deletions
@@ -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);
}
} }
@@ -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);
} }
@@ -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 "***";
}
}
@@ -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);
@@ -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
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;
}
}
+2 -1
View File
@@ -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;
+133 -72
View File
@@ -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>