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.service.AdminGatewayClient;
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.util.ApiServiceHelper;
import com.eactive.apim.portal.common.util.SecurityUtil;
@@ -82,6 +85,8 @@ public class MyAppController {
private final ApiServiceHelper apiServiceHelper;
private final FileTypeDetector fileTypeDetector;
private final AdminGatewayClient adminGatewayClient;
private final TwoFactorService twoFactorService;
private final TwoFactorProperties twoFactorProperties;
private static final long MAX_APP_ICON_BYTES = 2L * 1024 * 1024; // 2MB
@@ -893,6 +898,8 @@ public class MyAppController {
setupStepModel(model, 2);
model.addAttribute("apiServices", apiServices);
model.addAttribute("modification", modification);
// 최종 반영(저장) 직전 2FA 필요 여부 → 폼 JS 분기용
model.addAttribute("twofaRequired", isAppModifyTwofaRequired());
return new ModelAndView(API_KEY_MODIFY_STEP2);
}
@@ -927,6 +934,7 @@ public class MyAppController {
@RequestParam(value = "selectedApis", required = false) List<String> selectedApis,
@ModelAttribute("apiKeyModification") ApiKeyRegistrationDTO modification,
SessionStatus sessionStatus,
HttpSession session,
RedirectAttributes redirectAttributes) {
// 1단계가 완료되었는지 검증
@@ -950,6 +958,14 @@ public class MyAppController {
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();
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>
* <ul>
* <li>{@link Level#TWO_FACTOR} — 공통 2FA 팝업(휴대폰/이메일 인증번호). 진입 인터셉터 또는
* AJAX 401 신호로 유도. 예: Secret 조회/앱 해지/앱 정보수정.</li>
* AJAX 401 신호로 유도. 예: Secret 조회/앱 해지. 앱 정보수정은 최종 반영(commit)
* 직전에 컨트롤러가 통과권을 요구한다(다단계 진행 중 중복 인증 방지).</li>
* <li>{@link Level#PASSWORD} — 현재 비밀번호 재확인만 요구(2FA 없음). 별도 확인 페이지
* ({@code /auth/stepup/password})로 유도. 예: 내 정보 변경({@code /mypage}).</li>
* </ul>
@@ -43,12 +44,14 @@ public final class StepUpProtectedPaths {
public static final String REVEAL_SECRET = "/myapikey/credential/reveal-secret";
/** 앱 해지 신청 (AJAX POST) */
public static final String APP_KEY_DELETE = "/myapikey/api_key_delete";
/** 앱 정보 수정 페이지 진입 (GET) */
public static final String APP_MODIFY_STEP1 = "/myapikey/modify/step1";
/** 앱 정보 수정 최종 반영(commit, POST /modify/step2) — 반영 직전 2FA. 진입/중간 단계는 가드하지 않음 */
public static final String APP_MODIFY_COMMIT = "/myapikey/modify/step2";
/** 개인정보 변경 페이지 진입 (GET, 정확 일치) — PASSWORD 레벨 */
public static final String MYPAGE = "/mypage";
/** 비밀번호 변경 반영(commit, POST) — 반영 직전 2FA. 진입(GET)은 가드하지 않음 */
public static final String PASSWORD_CHANGE = "/password/change";
/** 회원 탈퇴 반영(commit, POST) — 반영 직전 2FA. 팝업(사유 입력) 후 프론트가 2FA 를 띄운다 */
public static final String WITHDRAW = "/withdraw";
/** PTL_PROPERTY 지점 키 접두 (전체 스위치 two-factor.stepup.enabled 와 구분) */
private static final String KEY_PREFIX = "two-factor.stepup.";
@@ -63,26 +66,31 @@ public final class StepUpProtectedPaths {
static {
Map<String, String> keys = new LinkedHashMap<>();
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(MYPAGE, KEY_PREFIX + "mypage");
keys.put(PASSWORD_CHANGE, KEY_PREFIX + "password-change");
keys.put(WITHDRAW, KEY_PREFIX + "withdraw");
PATH_TO_KEY = Collections.unmodifiableMap(keys);
Map<String, Level> levels = new LinkedHashMap<>();
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(MYPAGE, Level.PASSWORD);
levels.put(PASSWORD_CHANGE, Level.TWO_FACTOR);
levels.put(WITHDRAW, Level.TWO_FACTOR);
PATH_TO_LEVEL = Collections.unmodifiableMap(levels);
// 인터셉터 진입 자동 차단: 2FA 레벨 중 "진입 시점" 보호가 필요한 경로만.
// - PASSWORD_CHANGE 는 반영(POST commit) 직전에 컨트롤러가 통과권을 요구 → 제외
// - APP_MODIFY_COMMIT 도 동일 — 다단계(step1→step2) 진행 중 중복 인증을 막기 위해
// 최종 반영 직전에만 컨트롤러가 통과권을 요구 → 제외
// - MYPAGE 는 별도 확인 페이지로 컨트롤러가 유도(PASSWORD 레벨) → 제외
// - WITHDRAW 는 팝업(사유 입력)→2FA→제출 순서로 프론트가 유도하고
// 컨트롤러가 커밋 직전 통과권을 요구 → 제외
Set<String> guarded = new java.util.LinkedHashSet<>();
guarded.add(REVEAL_SECRET);
guarded.add(APP_MODIFY_STEP1);
guarded.add(APP_KEY_DELETE);
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;
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.pagerouter.PageHandler;
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 {
private final TwoFactorService twoFactorService;
private final DuplicateLoginService duplicateLoginService;
public LoginHandler(TwoFactorService twoFactorService) {
public LoginHandler(TwoFactorService twoFactorService, DuplicateLoginService duplicateLoginService) {
this.twoFactorService = twoFactorService;
this.duplicateLoginService = duplicateLoginService;
}
/**
@@ -55,7 +58,24 @@ public class LoginHandler implements PageHandler {
// 로그인 2FA 대기 상태면(1차 인증 통과 후) 추가 인증 팝업 자동 오픈 플래그를 내려준다.
// 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();
@@ -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;
import com.eactive.apim.portal.apps.session.entity.UserSession;
import com.eactive.apim.portal.apps.session.service.UserSessionService;
import lombok.RequiredArgsConstructor;
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.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
/**
* 세션 타이머/유휴 로그아웃/중복로그인 처리용 REST API.
* 세션 타이머/유휴 로그아웃 처리용 REST API.
*
* <p>중복 세션 확인은 비밀번호 검증 전 정보 노출 문제로 로그인 전 사전 체크
* ({@code /api/session/check-duplicate})를 제거하고, 1차 인증 통과 후
* {@code DuplicateLoginService} 가 처리한다.</p>
*
* <ul>
* <li>GET /api/session/status - 잔여 시간/유효성 폴링 (인증 필요)</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/csrf - 현재 CSRF 토큰 조회 (로그인 제출 직전 안전망)</li>
* </ul>
@@ -36,32 +35,8 @@ import java.util.Optional;
@RequiredArgsConstructor
public class SessionApiController {
private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
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);
}
/**
* 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,
SecurityContextHolder.getContext().getAuthentication());
redirectAttributes.addFlashAttribute("success", "비밀번호가 성공적으로 변경되었습니다.");
redirectAttributes.addFlashAttribute("success",
"비밀번호가 성공적으로 변경되었습니다.<br>새 비밀번호로 다시 로그인해주세요.");
return "redirect:/login";
} catch (IllegalArgumentException e) {
// 검증 실패(비밀번호 규칙/이력 등) — 사용자에게 안내, 스택은 불필요
@@ -182,6 +183,9 @@ public class AccountController {
// 기존 user 객체도 유지 (다른 곳에서 필요할 수 있으므로)
mav.addObject("user", user);
// 회원 탈퇴 팝업: 제출 전에 2FA 팝업을 띄울지 여부
mav.addObject("withdrawTwofaRequired", isWithdrawTwofaRequired());
// ROLE_USER인 경우 초대 여부 확인
if (currentUser.getRoleCode() == RoleCode.ROLE_USER) {
java.util.Optional<UserInvitation> pendingInvitation =
@@ -401,21 +405,35 @@ public class AccountController {
@PostMapping("/withdraw")
public String processWithdrawal(
@RequestParam(value = "withdrawReason", required = false) String withdrawReason,
HttpSession session,
RedirectAttributes redirectAttributes) {
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();
// 회원 탈퇴 처리
if (currentUser != null) {
userFacade.withdrawUser(currentUser.getId());
userFacade.withdrawUser(currentUser.getId(), withdrawReason.trim());
}
session.invalidate();
SecurityContextHolder.clearContext();
redirectAttributes.addFlashAttribute("success", "회원 탈퇴 신청이 완료 되었습니다. API Portal 회원 정보가 완전히 삭제 니다.");
redirectAttributes.addFlashAttribute("success", "회원 탈퇴 완료 되었습니다. API Portal 회원 정보가 완전히 삭제 되었습니다.");
return "redirect:/";
} catch (IllegalArgumentException e) {
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")
public String showVerificationEmailPage(Model model) {
try {
@@ -14,7 +14,7 @@ public interface UserFacade {
void updateCorporateManager(PortalUserDTO portalUserDTO);
void withdrawUser(String userId);
void withdrawUser(String userId, String withdrawalReason);
void activateUserByEmail(String email);
}
@@ -131,7 +131,7 @@ public class UserFacadeImpl implements UserFacade {
}
@Override
public void withdrawUser(String userId) {
public void withdrawUser(String userId, String withdrawalReason) {
PortalUser user = portalUserService.findById(userId);
// 법인 관리자 탈퇴 제한
@@ -147,7 +147,7 @@ public class UserFacadeImpl implements UserFacade {
// 메시지 요청정보 삭제
messageRequestFacade.deleteUserMessage(user.getUserName(),user.getLoginId());
portalUserService.deleteUser(user);
portalUserService.deleteUser(user, withdrawalReason);
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.util.EncryptionUtil;
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.portaluser.entity.PortalUser;
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
@@ -136,12 +135,9 @@ public class PortalUserAuthService implements UserDetailsService {
throw new UserNotFoundException("입력하신 사용자 정보가 올바르지 않습니다. 다시 확인해 주세요.");
}
return users.stream().map(user -> {
PortalUserDTO dto = portalUserMapper.toDTO(user);
dto.setMaskedEmailAddr(StringMaskingUtil.maskEmail(dto.getLoginId()));
dto.setLoginId(null);
return dto;
}).collect(Collectors.toList());
// 아이디 찾기: 이름+휴대폰 본인인증을 마친 사용자에게 보여주는 결과이므로
// 이메일(아이디)을 마스킹 없이 그대로 반환한다.
return users.stream().map(portalUserMapper::toDTO).collect(Collectors.toList());
} catch (UserNotFoundException e) {
throw e;
@@ -164,6 +160,9 @@ public class PortalUserAuthService implements UserDetailsService {
String tempPassword = EncryptionUtil.generateNewPassword();
portalUser.setPasswordHash(passwordEncoder.encode(tempPassword));
// 임시 비밀번호 발급 → 변경일을 null 로 초기화해 로그인 시 강제 비밀번호 변경을 유도한다
// (LoginFinalizer.applyPostLoginState 의 passwordChangeDate == null 분기)
portalUser.setPasswordChangeDate(null);
if ("Y".equalsIgnoreCase(portalUser.getAccountLockYn())) {
portalUser.setAccountLockYn("N");
}
@@ -280,7 +280,7 @@ public class PortalUserService {
return portalUserRepository.save(user);
}
public void deleteUser(PortalUser user) {
public void deleteUser(PortalUser user, String withdrawalReason) {
LocalDateTime now = LocalDateTime.now();
String withdrawalDate = now.format(DateTimeFormatter.ofPattern("yyyyMMddHHmm"));
@@ -290,6 +290,12 @@ public class PortalUserService {
user.setMobileNumber("");
user.setPasswordHash("");
// 탈퇴 사유 보존 (컬럼 길이 200 초과분은 잘라 저장)
if (withdrawalReason != null && withdrawalReason.length() > 200) {
withdrawalReason = withdrawalReason.substring(0, 200);
}
user.setWithdrawalReason(withdrawalReason);
user.setUserStatus(PortalUserEnums.UserStatus.REMOVED);
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.TwoFactorService;
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.portaluser.entity.PortalUser;
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
@@ -29,7 +30,9 @@ import java.io.IOException;
* 비워 사용자를 익명으로 되돌린 뒤 {@code /login?twofactor=1} 로 보낸다. 로그인 페이지가
* 공통 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>
*/
@Service
@@ -41,6 +44,7 @@ public class PortalAuthenticationSuccessHandler implements AuthenticationSuccess
private final LoginFinalizer loginFinalizer;
private final TwoFactorService twoFactorService;
private final TwoFactorProperties twoFactorProperties;
private final DuplicateLoginService duplicateLoginService;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
@@ -71,6 +75,23 @@ public class PortalAuthenticationSuccessHandler implements AuthenticationSuccess
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) → 기존과 동일하게 즉시 확정
String redirect = loginFinalizer.finalizeLogin(user, username, request, LoginType.NORMAL);
response.sendRedirect(redirect);
@@ -108,7 +108,6 @@ public class PortalConfigSecurity {
.csrf(csrf -> csrf
.csrfTokenRepository(csrfTokenRepository)
.ignoringRequestMatchers(new AntPathRequestMatcher("/_proxy/**/*"))
.ignoringRequestMatchers(new AntPathRequestMatcher("/api/session/check-duplicate"))
.ignoringRequestMatchers(new AntPathRequestMatcher("/internal/migration/**"))
)
// 로그인 페이지에 오래 머물러 세션(=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');
// 열 때마다 사유/에러 초기화
$('#withdrawalReasonInput').val('');
$('#withdrawalReasonError').hide();
$('#withdrawalReasonInput').off('input.withdrawal').on('input.withdrawal', function () {
$('#withdrawalReasonError').hide();
});
$('#withdrawalPopupConfirmButton').off('click').on('click', function () {
// 탈퇴 사유 필수 입력
var reason = $.trim($('#withdrawalReasonInput').val() || '');
if (!reason) {
$('#withdrawalReasonError').show();
$('#withdrawalReasonInput').focus();
return;
}
$('#withdrawalReasonHidden').val(reason);
customPopups.hideWithdrawal();
// 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 () {
@@ -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/test-env-notice' as *;
@use 'components/djb-inquiry-comments' as *;
@use 'components/password-policy' as *;
@use 'components/board-common' as *;
@use 'components/two-factor' as *;
@use 'components/password-policy' as *;
// 5. Page-specific styles
@use 'pages/index' as *;
@@ -8,7 +8,6 @@
.api-statistics-container {
margin: 0 auto;
padding: 48px 0px;
}
// 집계 안내 문구
@@ -34,10 +33,11 @@
// Search Section
.statistics-search-section {
display: flex;
flex-direction: column;
align-items: flex-end;
justify-content: space-between;
gap: 12px;
margin-bottom: 30px;
margin-top: 50px;
}
// 일별/월별 모드 토글
@@ -46,6 +46,7 @@
background: #eef1f5;
border-radius: 22px;
padding: 4px;
width: 150px;
}
.mode-tab {
@@ -347,9 +348,10 @@
font-size: 18px;
font-weight: 600;
margin: 0;
margin-bottom: 10px;
svg {
stroke: #fff;
stroke: #1e293b;
}
}
@@ -375,39 +377,89 @@
padding: 0;
}
.statistics-table {
.board-table {
width: 100%;
border-collapse: collapse;
border-top: 1px solid #c4c7c8;
background: #fff;
thead th {
padding: 16px 20px;
text-align: left;
font-weight: 500;
color: #666;
border-bottom: 1px solid #e0e0e0;
background: #fafafa;
}
tbody {
td {
padding: 16px 20px;
border-bottom: 1px solid #f0f0f0;
color: #333;
}
tr:hover {
.board-table-header {
display: flex;
align-items: center;
background: #f9f9f9;
height: 50px;
padding: 0 45px;
border-bottom: 1px solid #dedede;
@media (max-width: 768px) {
display: none;
}
.header-cell {
font-size: 14px;
font-family: 'Spoqa Han Sans Neo', sans-serif;
font-weight: 500;
color: #000;
text-align: center;
}
}
tfoot .total-row {
background: #e3f2fd;
.board-table-body {
display: flex;
flex-direction: column;
}
td {
padding: 16px 20px;
.board-table-row {
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;
.row-cell {
color: #0049B4;
border-bottom: none;
}
}
}
}
@@ -440,39 +492,64 @@
}
}
// Progress Bar
.progress-cell {
min-width: 200px;
.api-name-text {
display: inline-block;
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;
height: 12px;
background: #e0e0e0;
border-radius: 6px;
overflow: hidden;
}
.progress-success {
background: #4A90D9;
transition: width 0.3s ease;
.stats-empty {
display: flex;
align-items: center;
justify-content: center;
height: 100px;
width: 100%;
color: #64748b;
font-weight: 400;
font-size: 16px;
}
.progress-timeout {
background: #F5C36B;
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;
.progress-cell {
min-width: 200px;
}
// Responsive
@@ -480,6 +557,7 @@
.summary-content {
flex-direction: column;
gap: 30px;
align-items: center;
}
.summary-left,
@@ -487,6 +565,22 @@
.summary-right {
flex: none;
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 {
@@ -495,42 +589,101 @@
.summary-right {
flex-direction: row;
flex-wrap: wrap;
justify-content: center;
gap: 20px;
}
.rate-display {
border-bottom: none;
padding: 0;
}
}
@media (max-width: 768px) {
.api-statistics-container {
padding: 20px 16px;
}
@media (max-width: $breakpoint-sm) {
// .api-statistics-container {
// padding: 20px 16px;
// }
.statistics-summary-card {
padding: 24px 20px;
}
.app-toggle-buttons {
flex-wrap: wrap;
}
.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;
.statistics-search-section {
flex-direction: column;
align-items: stretch;
gap: 16px;
}
.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;
}
.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
.dt-actions {
display: flex;
+138 -77
View File
@@ -10,12 +10,11 @@
display: flex;
align-items: center;
justify-content: center;
background: #EDF9FE; // Light blue background from Figma
padding: 40px 20px;
padding: 100px 20px;
position: relative;
border-radius: 12px;
margin-top: 60px;
margin-bottom: 60px;
margin-bottom: 50px;
}
.login-container {
@@ -37,60 +36,104 @@
}
}
.login-logo {
width: 90px;
height: 90px;
margin-bottom: 30px;
img {
width: 100%;
height: 100%;
object-fit: contain;
}
}
.login-message {
font-family: $font-family-primary;
font-size: 20px;
font-weight: 400;
color: #000000;
text-align: center;
.login-header {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 40px;
width: 100%;
.header-main-row {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
margin-bottom: 12px;
}
}
.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-size: 36px;
font-weight: 700;
color: #000000;
margin: 0;
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 {
width: 100%;
margin-bottom: 0;
.form-group {
margin-bottom: 24px;
margin-bottom: 16px;
position: relative;
&:last-of-type {
margin-bottom: 0;
&.input-with-icon {
.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 {
width: 100%;
height: 70px;
padding: 0 32px;
height: 58px;
padding: 0 20px;
font-family: $font-family-primary;
font-size: 16px;
font-weight: 400;
color: #5F666C;
background: #FFFFFF;
border: 1px solid #DDDDDD;
border-radius: 12px;
font-weight: 300;
color: #000000;
background: #fcfcfc;
border: 1px solid #dfdfdf;
border-radius: 5px;
outline: none;
transition: all 0.3s ease;
&::placeholder {
color: #5F666C;
color: #767e89;
}
&:focus {
border-color: #0049B4;
border-color: #1b4ab7;
background: #ffffff;
}
&.error {
@@ -101,17 +144,16 @@
.form-checkbox {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 7px;
margin-top: 24px;
margin-bottom: 22px;
margin-right: 10px;
justify-content: flex-start;
gap: 8px;
margin-top: 14px;
margin-bottom: 30px;
input[type="checkbox"] {
width: 20px;
height: 20px;
border: 1.5px solid #000000;
border-radius: 4px;
width: 14px;
height: 14px;
border: 1px solid #dfdfdf;
border-radius: 2px;
cursor: pointer;
appearance: none;
background: #FFFFFF;
@@ -119,8 +161,8 @@
flex-shrink: 0;
&:checked {
background: #FFFFFF;
border-color: #2C2C2C;
background: #1b4ab7;
border-color: #1b4ab7;
&::after {
content: '';
@@ -128,22 +170,22 @@
left: 50%;
top: 45%;
transform: translate(-50%, -50%) rotate(45deg);
width: 6px;
height: 12px;
border: solid #2C2C2C;
border-width: 0 2px 2px 0;
width: 3px;
height: 6px;
border: solid #ffffff;
border-width: 0 1.5px 1.5px 0;
}
}
}
label {
font-family: $font-family-primary;
font-size: 16px;
font-weight: 400;
color: #000000;
font-size: 12px;
font-weight: 300;
color: #767e89;
cursor: pointer;
user-select: none;
line-height: 24px;
line-height: 1;
white-space: nowrap;
}
}
@@ -151,26 +193,26 @@
.login-button {
width: 100%;
height: 80px;
height: 58px;
padding: 10px;
font-family: $font-family-primary;
font-size: 20px;
font-weight: 700;
font-size: 16px;
font-weight: 500;
color: #FFFFFF;
background: #0049B4;
background: #1b4ab7;
border: none;
border-radius: 12px;
border-radius: 5px;
cursor: pointer;
transition: all 0.3s ease;
margin-bottom: 40px;
line-height: 1;
margin-bottom: 30px;
line-height: 20px;
&:hover {
background: darken(#0049B4, 5%);
background: darken(#1b4ab7, 5%);
}
&:active {
background: darken(#0049B4, 10%);
background: darken(#1b4ab7, 10%);
}
&:disabled {
@@ -179,31 +221,51 @@
}
}
.login-links {
.login-links-container {
display: flex;
justify-content: center;
align-items: center;
gap: 26px;
flex-wrap: wrap;
width: 100%;
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-size: 16px;
font-size: 14px;
font-weight: 400;
color: #000000;
color: #2a69de;
text-decoration: none;
transition: color 0.3s ease;
line-height: 1;
transition: all 0.2s ease;
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 {
color: #0049B4;
text-decoration: underline;
}
background: darken(#ecf0fa, 4%);
}
.link-separator {
color: #000000;
font-size: 16px;
&:active {
background: darken(#ecf0fa, 8%);
}
}
}
@@ -282,9 +344,8 @@
@include respond-to('sm') {
.login-page {
// Figma: 전체 배경 #ebfbff, 풀 높이
background: #ebfbff;
min-height: calc(100vh - 44px); // 모바일 헤더 높이 제외
padding: 40px 20px;
padding: 100px 20px;
margin-top: 0;
margin-bottom: 0;
border-radius: 0;
@@ -26,13 +26,14 @@
// -----------------------------------------------------------------------------
// Corporate Manager Profile Form Styles
// -----------------------------------------------------------------------------
.corp-manager-wrapper, .password-change-wrapper {
.corp-manager-wrapper,
.password-change-wrapper {
width: 100%;
margin: 0 auto;
padding: 40px 0;
// padding: 40px 0;
.page-outer-title {
font-size: 36px;
font-size: 24px;
font-weight: 700;
color: #000;
margin-bottom: 24px;
@@ -87,6 +88,11 @@
flex-direction: column;
gap: 13px;
}
@media (max-width: $breakpoint-sm) {
flex-direction: column;
gap: 26px;
}
}
}
@@ -218,7 +224,7 @@
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 40px;
margin-top: 0;
}
.btn-withdrawal {
@@ -291,6 +297,14 @@
font-weight: 500;
margin: 0;
}
&.danger {
background: #fdf3f3;
p {
color: #f4253c;
}
}
}
.password-rules-list {
@@ -93,7 +93,9 @@
th:if="${notice.noticeType == '4'}">테스트</span>
<span class="notice-type-badge notice-type-badge--normal"
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()}">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
@@ -39,11 +39,9 @@
<section class="service-content">
<!-- 상단: 최근 작성한 글 목록 -->
<div class="recent-apps">
<div class="recent-apps-header-group">
<p class="recent-apps-title" style="font-size: 24px; font-weight: 700; color: #000; margin-bottom: 8px;">
피드백 / 개선 요청</p>
<p class="recent-apps-subtitle" style="font-size: 14px; color: #7f8a95; margin-bottom: 24px;">최근 최대 3개의 나의
피드백/ 개선 요청을 보여줍니다.</p>
<div class="board-header">
<h2 class="board-title">피드백 / 개선 요청</h2>
<p class="board-desc">최근 최대 3개의 나의 피드백/ 개선 요청을 보여줍니다.</p>
</div>
<ul class="recent-apps-list" th:if="${recentApplications != null and !recentApplications.isEmpty()}">
@@ -73,10 +71,10 @@
<!-- 하단: 신규 작성 폼 카드 -->
<div class="djb-board-write-container">
<!-- Title Bar -->
<div class="djb-board-write-header" style="margin-bottom: 30px;">
<h2 class="title" style="font-size: 24px; font-weight: 700; color: #000; margin-bottom: 8px;">피드백 / 개선 요청
<div class="board-header">
<h2 class="board-title">피드백 / 개선 요청
</h2>
<p class="subtitle" style="font-size: 14px; color: #7f8a95; margin: 0;">DJ Bank은 온라인 비즈니스 혁신을 위한 피드백/개선요청을
<p class="board-desc">DJ Bank은 온라인 비즈니스 혁신을 위한 피드백/개선요청을
환영합니다.</p>
</div>
@@ -32,7 +32,7 @@
<div class="found-users-list">
<div class="found-user-item" th:each="user, stat : ${foundUsers}">
<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">
(<th:block th:text="${#temporals.format(user.createdDate, 'yyyy.MM.dd')}">2024.01.01</th:block> 가입)
</span>
@@ -1,18 +1,26 @@
<!doctype html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/djbank_base_layout}">
<body>
<th:block layout:fragment="contentFragment">
<div class="login-page">
<div class="login-container">
<div class="login-card">
<!-- Logo -->
<div class="login-logo">
<img th:src="@{/img/logo/logo_box.png}" alt="DJBank">
<!-- Logo & Title -->
<div class="login-header">
<div class="header-main-row">
<div class="login-lock-icon">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="#000"
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>
</div>
<h2 class="login-title">로그인</h2>
</div>
<p class="login-message-sub">DJ Bank에 오신걸 환영합니다</p>
</div>
<!-- Header Message -->
<p class="login-message">로그인 하시면 더 많은 서비스를 이용하실 수 있습니다.</p>
<!-- Alert Messages -->
<div th:if="${param.logout}" class="login-alert alert-info">
@@ -33,30 +41,36 @@
</div>
<!-- Login Form -->
<form id="loginForm" role="form" name="loginForm" th:action="@{/actionLogin.do}" method="post" class="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 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 type="password"
id="password"
name="password"
class="form-input"
placeholder="비밀번호"
required>
<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 -->
@@ -72,12 +86,10 @@
</button>
<!-- 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 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>
<!-- Loading State -->
@@ -137,34 +149,21 @@
});
}
// 로그인 전 중복 접속 확인 → 중복 시 기존 세션 강제 로그아웃 여부 질의
function checkDuplicateAndLogin(form) {
var loginId = form.id.value;
$.ajax({
url: /*[[@{/api/session/check-duplicate}]]*/ '/api/session/check-duplicate',
type: 'POST',
data: { loginId: loginId },
success: function (data) {
if (data && data.duplicateSession) {
$('#loginLoading').removeClass('active');
var msg = '해당 계정은 이미 다른 곳(' + data.ipAddress + ')에서 접속 중입니다.<br>'
+ '접속 시각: ' + data.loginTime + '<br><br>'
// 동시 접속 확인은 비밀번호 검증(1차 인증) 통과 후 서버가 안내한다
// (2FA pending 또는 /login?duplicate=1 대기 상태에서 duplicateInfo 모델로 수신).
function duplicateConfirmMessage(info) {
return '해당 계정은 이미 다른 곳(' + info.ipAddress + ')에서 접속 중입니다.<br>'
+ '접속 시각: ' + info.loginTime + '<br><br>'
+ '기존 접속을 해제하고 로그인하시겠습니까?';
customPopups.showConfirm(msg, function (confirmed) {
if (confirmed) {
$('#loginLoading').addClass('active');
form.submit();
}
});
} else {
form.submit();
}
},
error: function () {
// 중복 체크 실패 시 로그인은 그대로 진행
form.submit();
}
});
// 세션 CSRF 헤더 (meta[name=_csrf]) — 인증 후 확인/취소 POST 용
function csrfHeaders() {
var t = document.querySelector('meta[name="_csrf"]');
var h = document.querySelector('meta[name="_csrf_header"]');
var headers = {};
headers[h ? h.getAttribute('content') : 'X-XSRF-TOKEN'] = t ? t.getAttribute('content') : '';
return headers;
}
function fnInit() {
@@ -230,7 +229,7 @@
customPopups.showAlert('[[#{login.passLengthShort}]]');
} else {
refreshCsrfAndThen(form, function () {
checkDuplicateAndLogin(form);
form.submit();
});
}
}
@@ -248,9 +247,12 @@
}
});
// 로그인 2FA: 1차 인증 통과 후 pending 상태면 추가 인증 팝업 자동 오픈
// 1차 인증(비밀번호) 통과 후 흐름: [동시 접속 확인] → [2FA] 순서.
var twoFactorPending = [[${twoFactorPending}]];
if (twoFactorPending && typeof TwoFactorAuth !== 'undefined') {
var duplicateConfirmPending = [[${duplicateConfirmPending}]];
var duplicateInfo = [[${duplicateInfo}]];
function openLoginTwoFactor() {
TwoFactorAuth.open({
mode: 'login',
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();
});
</script>
</th:block>
</html>
@@ -143,13 +143,6 @@
</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">
<label class="s1-label">화이트 리스트 <span class="s1-required">*</span></label>
@@ -357,7 +350,6 @@
form.addEventListener('submit', function (e) {
const name = document.getElementById('appName').value.trim();
const desc = textarea.value.trim();
const url = document.getElementById('callbackUrl').value.trim();
if (!name) {
e.preventDefault();
@@ -372,15 +364,6 @@
textarea.focus();
return;
}
if (url) {
try { new URL(url); } catch (_) {
e.preventDefault();
customPopups.showAlert('올바른 URL 형식이 아닙니다.\n예: https://example.com/callback');
document.getElementById('callbackUrl').focus();
return;
}
}
});
});
</script>
@@ -111,6 +111,38 @@
</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>
<!-- 화면 전체 오버레이/플로팅은 body 직속(pagePopups)으로 렌더 → wrapper transform·overflow 영향 없이 뷰포트 기준 중앙 정렬 -->
@@ -151,7 +151,7 @@
</div>
<div class="s3-message-wrapper">
<h1 class="s3-success-title">앱 수정이 완료되었습니다.</h1>
<h1 class="s3-success-title">앱 수정 신청이 완료되었습니다.</h1>
<p class="s3-success-desc">
<span class="s3-highlight">담당자 승인 후 변경 사항이 적용됩니다.</span>
<br>
@@ -25,9 +25,13 @@
<!-- 모든 스타일은 detail-wrap 안에서만 적용 -->
<div class="detail-wrap">
<!-- Title Bar (Figma 및 스크린샷 대응) -->
<div class="common-title-bar">
<h2 class="common-title" th:text="${appRequest.approval != null and appRequest.approval.approvalStatus != null and appRequest.approval.approvalStatus.toString() == 'APPROVED' ? '인증키 정보' : '신청 상세'}">신청 상세</h2>
<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>
<div class="board-header">
<h2 class="board-title"
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>
<!-- App Basic Info Card -->
@@ -49,7 +53,7 @@
<div class="dt-app-status-row" th:if="${appRequest.approval != null}">
<span class="dt-status-badge"
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}">
대기중
</span>
@@ -80,7 +84,8 @@
<svg width="20" height="20" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2">
<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>
<span>복사</span>
</button>
@@ -176,7 +181,8 @@
</div>
<!-- 승인일시 (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">
<label class="dt-label">승인일시</label>
<div class="dt-input-group">
@@ -204,11 +210,16 @@
</div>
<!-- 내부 결재 진행중: 취소 불가 안내 -->
<p class="dt-cancel-notice" sec:authorize="hasRole('ROLE_API_KEY_REQUEST')"
th:if="${appRequest.approval != null and appRequest.approval.approvalStatus != null and
<div class="info-notice-box danger" sec:authorize="hasRole('ROLE_API_KEY_REQUEST')" th:if="${appRequest.approval != null and appRequest.approval.approvalStatus != null and
appRequest.approval.approvalStatus.toString() == 'PROCESSING'}">
내부 결재가 진행 중이라 신청을 취소할 수 없습니다. 취소가 필요한 경우 관리자에게 문의해 주세요.
</p>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#f4253c"
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 -->
<div class="dt-actions">
@@ -1,11 +1,11 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
xmlns:sec="http://www.thymeleaf.org/extras/spring-security"
layout:decorate="~{layout/djbank_title_layout}">
<html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
xmlns:sec="http://www.thymeleaf.org/extras/spring-security" layout:decorate="~{layout/djbank_title_layout}">
<head>
<title>API 키 상세</title>
</head>
<body>
<section layout:fragment="title">
<div class="page-title-banner">
@@ -24,16 +24,18 @@
<div class="app-management-content">
<div class="detail-wrap">
<!-- Title Bar -->
<div class="common-title-bar">
<h2 class="common-title">인증키 정보</h2>
<span class="common-subtitle">등록된 앱의 상세 정보를 확인할 수 있습니다.</span>
<div class="board-header">
<h2 class="board-title">인증키 정보</h2>
<span class="board-desc">등록된 앱의 상세 정보를 확인할 수 있습니다.</span>
</div>
<!-- App Basic Info Card -->
<div class="dt-app-card">
<div class="dt-app-icon-box">
<img th:if="${apiKey.appIconFileId != null}" th:src="@{/file/download(fileSn=1,fileId=${apiKey.appIconFileId})}" alt="앱 아이콘">
<svg th:unless="${apiKey.appIconFileId != null}" width="46" height="46" viewBox="0 0 24 24" fill="none" stroke="#64748b" stroke-width="1.5">
<img th:if="${apiKey.appIconFileId != null}"
th:src="@{/file/download(fileSn=1,fileId=${apiKey.appIconFileId})}" alt="앱 아이콘">
<svg th:unless="${apiKey.appIconFileId != null}" width="46" height="46" viewBox="0 0 24 24" fill="none"
stroke="#64748b" stroke-width="1.5">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
<circle cx="8.5" cy="8.5" r="1.5"></circle>
<polyline points="21 15 16 10 5 21"></polyline>
@@ -41,10 +43,12 @@
</div>
<div class="dt-app-info">
<div class="dt-app-status-row">
<span th:classappend="${apiKey.appstatus == '1' ? 'status-approved' : 'status-inactive'}" class="dt-status-badge" th:text="${apiKey.appstatus == '1' ? '승인' : '비활성화'}">승인</span>
<span th:classappend="${apiKey.appstatus == '1' ? 'status-approved' : 'status-inactive'}"
class="dt-status-badge" th:text="${apiKey.appstatus == '1' ? '승인' : '비활성화'}">승인</span>
</div>
<h3 class="dt-app-name" th:text="${apiKey.clientname}">앱 이름</h3>
<p class="dt-app-desc" th:text="${apiKey.appDescription != null ? apiKey.appDescription : '앱 설명이 없습니다.'}">앱 설명이 표시됩니다.</p>
<p class="dt-app-desc"
th:text="${apiKey.appDescription != null ? apiKey.appDescription : '앱 설명이 없습니다.'}">앱 설명이 표시됩니다.</p>
</div>
</div>
@@ -57,8 +61,10 @@
<label class="dt-label">Client ID</label>
<div class="dt-input-group">
<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)">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<button type="button" class="dt-btn-copy" th:data-secret="${apiKey.clientid}"
onclick="copyToClipboardFromButton(this)">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2">
<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>
</svg>
@@ -73,7 +79,8 @@
<div class="dt-col">
<label class="dt-label">
Client Secret
<button type="button" class="btn-guide-info" onclick="showLostKeyGuide()" title="비밀정보 안내" aria-label="비밀정보 안내"
<button type="button" class="btn-guide-info" onclick="showLostKeyGuide()" title="비밀정보 안내"
aria-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>
@@ -81,8 +88,13 @@
<th:block th:if="${secretAvailable}">
<!-- View Button (shown initially) -->
<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()">
<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;">
<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()">
<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;">
<circle cx="11" cy="11" r="8"></circle>
<path d="m21 21-4.35-4.35"></path>
</svg>
@@ -94,15 +106,18 @@
<div id="revealedSecretBox" style="display: none; width: 100%;">
<div class="dt-input-group">
<div class="dt-input-box" id="revealedSecretValue" style="flex: 1;"></div>
<button type="button" class="dt-btn-copy" id="revealedSecretCopyBtn" onclick="copyToClipboardFromButton(this)">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<button type="button" class="dt-btn-copy" id="revealedSecretCopyBtn"
onclick="copyToClipboardFromButton(this)">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2">
<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>
</svg>
<span>복사</span>
</button>
</div>
<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;">
<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;">
⚠ 이 값은 <strong>지금 한 번만</strong> 표시되며, 이후에는 다시 조회할 수 없습니다.<br>
반드시 <strong>안전한 곳에 복사하여 보관</strong>해 주세요.
</p>
@@ -110,10 +125,12 @@
</th:block>
<!-- 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"
style="display:flex;align-items:center;gap:12px;">
<span class="secret-revealed-text" style="color:#888;font-size:14px;">이미 1회 노출되어 삭제된 인증정보입니다.</span>
<button type="button" class="btn-guide-link" onclick="showLostKeyGuide()"
style="background:none;border:none;color:#2b6cb0;text-decoration:underline;cursor:pointer;font-size:13px;padding:0;">분실 시 안내</button>
style="background:none;border:none;color:#2b6cb0;text-decoration:underline;cursor:pointer;font-size:13px;padding:0;">분실
시 안내</button>
</div>
</div>
</div>
@@ -135,7 +152,8 @@
<div class="dt-col">
<label class="dt-label">화이트 리스트</label>
<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>
@@ -161,7 +179,8 @@
<div class="dt-col">
<label class="dt-label">최종 수정일자</label>
<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>
@@ -170,8 +189,10 @@
<!-- Bottom Navigation Actions -->
<div class="dt-actions" style="justify-content: flex-end; gap: 11px; margin-top: 30px;">
<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>
<a sec:authorize="hasRole('ROLE_API_KEY_REQUEST')" class="dt-btn-blue" th:href="@{/myapikey/modify/step1(clientId=${apiKey.clientid})}">수정</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>
<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 -->
@@ -197,39 +218,12 @@
);
}
// 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회 노출 + 서버측 물리 삭제)
function showPasswordPrompt() {
customPopups.showPasswordInput({
title: 'Client Secret 조회',
message: '보안을 위해 비밀번호를 입력해주세요.<br>조회 즉시 값은 영구 삭제됩니다.',
onConfirm: function (password) {
doRevealSecret(password);
},
onCancel: function() {
// User cancelled - do nothing
}
});
}
// Client Secret 조회 요청 (step-up 필요 시 2FA 후 동일 요청 재시도)
function doRevealSecret(password) {
$.ajax({
url: /*[[@{/myapikey/credential/reveal-secret}]]*/ '/myapikey/credential/reveal-secret',
type: 'POST',
@@ -254,14 +248,14 @@
} else {
customPopups.showPasswordError(response.message || '비밀번호가 일치하지 않습니다.');
}
}).fail(function(jqXHR) {
if (isStepUpRequired(jqXHR)) {
customPopups.hidePasswordInput();
requireStepUp('/myapikey/credential/reveal-secret', function() { doRevealSecret(password); });
return;
}
}).fail(function () {
customPopups.showPasswordError('오류가 발생했습니다. 다시 시도해주세요.');
});
},
onCancel: function () {
// User cancelled - do nothing
}
});
}
// Copy to clipboard function - called from button with data-secret attribute
@@ -313,7 +307,7 @@
$('.loading-overlay').show();
$.ajax({
url: '/myapikey/api_key_delete',
url: /*[[@{/myapikey/api_key_delete}]]*/ '/myapikey/api_key_delete',
type: 'POST',
contentType: 'application/json',
data: JSON.stringify({ clientId: clientId }),
@@ -339,10 +333,29 @@
});
}
// 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 () { /* 취소: 원 작업 중단 */ }
});
}
/*]]>*/
</script>
</th:block>
</body>
</html>
@@ -29,7 +29,8 @@
<path d="M12 16v-4"></path>
<path d="M12 8h.01"></path>
</svg>
<p>소중한 계정 보호를 위해 비밀번호를 변경해 주세요!</p>
<p>소중한 계정 보호를 위해 비밀번호를 변경해 주세요!<br>
비밀번호 변경이 완료되면 자동으로 로그아웃되며, 새 비밀번호로 다시 로그인해야 합니다.</p>
</div>
<div class="form-row">
@@ -9,8 +9,8 @@
</div>
</section>
<section layout:fragment="contentFragment">
<div class="service-main app-management-layout">
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('apiKey')}"></th:block>
<div class="service-main container" style="padding-top: 70px; padding-bottom:100px;">
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('profile')}"></th:block>
<div class="app-management-content">
<div class="corp-manager-wrapper">
<h2 class="page-outer-title">법인 기본 정보</h2>
@@ -11,93 +11,7 @@
</section>
<th:block layout:fragment="contentFragment">
<link rel="stylesheet" type="text/css" th:href="@{/css/api-statistics.css}"/>
<style>
/* 통계 테이블 (실제 table 태그) 공통 */
.api-statistics-container .stats-table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
background: #fff;
}
.api-statistics-container .stats-table th,
.api-statistics-container .stats-table td {
padding: 14px 12px;
border-bottom: 1px solid #e2e8f0;
vertical-align: middle;
}
.api-statistics-container .stats-table thead th {
text-align: center;
background: #f8fafc;
color: #475569;
font-weight: 600;
white-space: nowrap;
}
.api-statistics-container .stats-table tfoot td {
font-weight: 600;
background: #f8fafc;
}
/* API별 표: auto layout — 숫자열은 내용에 맞춰 최소(nowrap), 통계는 고정, API명이 남은 폭 전체 */
.api-statistics-container .stats-table--api { table-layout: auto; }
.api-statistics-container .stats-table--api .col-name {
text-align: left;
width: 100%;
white-space: normal;
word-break: break-word;
padding-left: 4px;
}
/* API명 헤더 셀은 가운데 정렬 (데이터 셀은 좌측 유지) */
.api-statistics-container .stats-table--api thead .col-name {
text-align: center;
}
.api-statistics-container .stats-table--api .col-num {
text-align: center;
white-space: nowrap;
width: 1%;
}
.api-statistics-container .stats-table--api .col-chart {
text-align: center;
width: 180px;
min-width: 180px;
}
.api-statistics-container .stats-table--api .col-chart .stats-progress {
min-width: 150px;
}
.api-statistics-container .stats-table--api .api-icon {
display: inline-flex;
align-items: center;
justify-content: center;
vertical-align: middle;
margin-right: 6px;
}
.api-statistics-container .stats-table--api .api-icon svg { display: block; }
.api-statistics-container .stats-table--api .api-name-text { vertical-align: middle; }
/* 일별/기간별 표: fixed layout — 5열 동일 너비, 전부 가운데 */
.api-statistics-container .stats-table--period { table-layout: fixed; }
.api-statistics-container .stats-table--period th,
.api-statistics-container .stats-table--period td {
text-align: center;
width: 20%;
white-space: nowrap;
}
/* progress bar */
.api-statistics-container .stats-progress {
width: 100%;
height: 8px;
background: #f1f5f9;
border-radius: 4px;
display: flex;
overflow: hidden;
}
/* empty state */
.api-statistics-container .stats-empty td {
text-align: center;
padding: 40px 0;
color: #64748b;
font-weight: 400;
font-size: 16px;
}
</style>
<!-- <link rel="stylesheet" type="text/css" th:href="@{/css/api-statistics.css}" /> -->
<div class="service-main app-management-layout">
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('statistics')}"></th:block>
<div class="app-management-content">
@@ -156,9 +70,7 @@
<div class="app-select-wrapper">
<select id="appSelect" class="app-select">
<option value="">전체 앱</option>
<option th:each="app : ${appList}"
th:value="${app.clientId}"
th:text="${app.clientName}"
<option th:each="app : ${appList}" th:value="${app.clientId}" th:text="${app.clientName}"
th:selected="${searchDto.clientId != null and searchDto.clientId == app.clientId}">
앱 이름
</option>
@@ -167,32 +79,45 @@
<div class="summary-info">
<p class="summary-total-text">
총 API 호출 수는 <strong id="totalCount" th:text="${#numbers.formatInteger(summary.totalCount, 0, 'COMMA')}">0</strong>건입니다.
총 API 호출 수는 <strong id="totalCount"
th:text="${#numbers.formatInteger(summary.totalCount, 0, 'COMMA')}">0</strong>건입니다.
</p>
<div class="summary-detail-list">
<div class="summary-detail-item success">
<span class="detail-icon">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><circle cx="8" cy="8" r="6"/></svg>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<circle cx="8" cy="8" r="6" />
</svg>
</span>
<span class="detail-label">성공</span>
<span class="detail-value" id="successCount" th:text="${#numbers.formatInteger(summary.successCount, 0, 'COMMA')}">0</span>
<span class="detail-percent">(<span id="successRateText" th:text="${summary.totalCount == 0 ? '-' : summary.successRate}">0</span>%)</span>
<span class="detail-value" id="successCount"
th:text="${#numbers.formatInteger(summary.successCount, 0, 'COMMA')}">0</span>
<span class="detail-percent">(<span id="successRateText"
th:text="${summary.totalCount == 0 ? '-' : summary.successRate}">0</span>%)</span>
</div>
<div class="summary-detail-item timeout">
<span class="detail-icon">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><circle cx="8" cy="8" r="6"/></svg>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<circle cx="8" cy="8" r="6" />
</svg>
</span>
<span class="detail-label">타임아웃</span>
<span class="detail-value" id="timeoutCount" th:text="${#numbers.formatInteger(summary.timeoutCount, 0, 'COMMA')}">0</span>
<span class="detail-percent">(<span id="timeoutRateText" th:text="${summary.totalCount == 0 ? '-' : summary.timeoutRate}">0</span>%)</span>
<span class="detail-value" id="timeoutCount"
th:text="${#numbers.formatInteger(summary.timeoutCount, 0, 'COMMA')}">0</span>
<span class="detail-percent">(<span id="timeoutRateText"
th:text="${summary.totalCount == 0 ? '-' : summary.timeoutRate}">0</span>%)</span>
</div>
<div class="summary-detail-item failure">
<span class="detail-icon">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><circle cx="8" cy="8" r="6"/></svg>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<circle cx="8" cy="8" r="6" />
</svg>
</span>
<span class="detail-label">실패</span>
<span class="detail-value" id="errorCount" th:text="${#numbers.formatInteger(summary.errorCount, 0, 'COMMA')}">0</span>
<span class="detail-percent">(<span id="errorRateText" th:text="${summary.totalCount == 0 ? '-' : summary.errorRate}">0</span>%)</span>
<span class="detail-value" id="errorCount"
th:text="${#numbers.formatInteger(summary.errorCount, 0, 'COMMA')}">0</span>
<span class="detail-percent">(<span id="errorRateText"
th:text="${summary.totalCount == 0 ? '-' : summary.errorRate}">0</span>%)</span>
</div>
</div>
</div>
@@ -202,18 +127,15 @@
<div class="summary-center">
<svg id="donutChart" width="200" height="200" viewBox="0 0 200 200">
<circle class="donut-bg" cx="100" cy="100" r="70" fill="none" stroke="#e0e0e0" stroke-width="24" />
<circle id="donutSuccess" class="donut-segment" cx="100" cy="100" r="70" fill="none"
stroke="#4A90D9" stroke-width="24" stroke-linecap="butt"
th:attr="stroke-dasharray=${summary.successRate * 4.398} + ' 439.8'"
stroke-dashoffset="0" transform="rotate(-90 100 100)"/>
<circle id="donutTimeout" class="donut-segment" cx="100" cy="100" r="70" fill="none"
stroke="#F5C36B" stroke-width="24" stroke-linecap="butt"
th:attr="stroke-dasharray=${summary.timeoutRate * 4.398} + ' 439.8',
stroke-dashoffset='-' + ${summary.successRate * 4.398}"
<circle id="donutSuccess" class="donut-segment" cx="100" cy="100" r="70" fill="none" stroke="#4A90D9"
stroke-width="24" stroke-linecap="butt"
th:attr="stroke-dasharray=${summary.successRate * 4.398} + ' 439.8'" stroke-dashoffset="0"
transform="rotate(-90 100 100)" />
<circle id="donutError" class="donut-segment" cx="100" cy="100" r="70" fill="none"
stroke="#F5A3B5" stroke-width="24" stroke-linecap="butt"
th:attr="stroke-dasharray=${summary.errorRate * 4.398} + ' 439.8',
<circle id="donutTimeout" class="donut-segment" cx="100" cy="100" r="70" fill="none" stroke="#F5C36B"
stroke-width="24" stroke-linecap="butt" th:attr="stroke-dasharray=${summary.timeoutRate * 4.398} + ' 439.8',
stroke-dashoffset='-' + ${summary.successRate * 4.398}" transform="rotate(-90 100 100)" />
<circle id="donutError" class="donut-segment" cx="100" cy="100" r="70" fill="none" stroke="#F5A3B5"
stroke-width="24" stroke-linecap="butt" th:attr="stroke-dasharray=${summary.errorRate * 4.398} + ' 439.8',
stroke-dashoffset='-' + ${(summary.successRate + summary.timeoutRate) * 4.398}"
transform="rotate(-90 100 100)" />
</svg>
@@ -224,19 +146,22 @@
<div class="rate-display success-rate">
<span class="rate-indicator success"></span>
<span class="rate-label">성공률</span>
<span class="rate-value" id="successRate" th:text="${summary.totalCount == 0 ? '-' : summary.successRate}">0</span>
<span class="rate-value" id="successRate"
th:text="${summary.totalCount == 0 ? '-' : summary.successRate}">0</span>
<span class="rate-percent">%</span>
</div>
<div class="rate-display timeout-rate">
<span class="rate-indicator timeout"></span>
<span class="rate-label">타임아웃율</span>
<span class="rate-value" id="timeoutRate" th:text="${summary.totalCount == 0 ? '-' : summary.timeoutRate}">0</span>
<span class="rate-value" id="timeoutRate"
th:text="${summary.totalCount == 0 ? '-' : summary.timeoutRate}">0</span>
<span class="rate-percent">%</span>
</div>
<div class="rate-display failure-rate">
<span class="rate-indicator failure"></span>
<span class="rate-label">실패률</span>
<span class="rate-value" id="errorRate" th:text="${summary.totalCount == 0 ? '-' : summary.errorRate}">0</span>
<span class="rate-value" id="errorRate"
th:text="${summary.totalCount == 0 ? '-' : summary.errorRate}">0</span>
<span class="rate-percent">%</span>
</div>
</div>
@@ -245,83 +170,104 @@
<!-- API별 통계 Table Section -->
<div class="statistics-table-section" style="margin-top: 40px;">
<div class="table-controls" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; background: none; border: none; padding: 0;">
<h3 class="table-title" style="font-size: 20px; font-weight: 700; color: #1e293b; display: flex; align-items: center; gap: 8px; margin: 0;">
<div class="table-controls">
<h3 class="table-title">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 3v18h18" />
<path d="M18 9l-5 5-4-4-4 4" />
</svg>
API별 통계
</h3>
<!-- 다운로드 버튼은 UI에서 제거됨 (백엔드 /statistics/api/download 는 유지) -->
</div>
<table class="stats-table stats-table--api">
<thead>
<tr>
<th class="col-name">API명</th>
<th class="col-num">API호출수</th>
<th class="col-num">성공</th>
<th class="col-num">실패</th>
<th class="col-num">타임아웃</th>
<th class="col-chart">통계</th>
</tr>
</thead>
<tbody id="statisticsTableBody">
<tr th:each="detail : ${details}">
<td class="col-name">
<div class="board-table">
<!-- Table Header -->
<div class="board-table-header">
<div class="header-cell col-name" style="flex: 2;">API명</div>
<div class="header-cell col-num" style="width: 100px;">API호출수</div>
<div class="header-cell col-num" style="width: 100px;">성공</div>
<div class="header-cell col-num" style="width: 100px;">실패</div>
<div class="header-cell col-num" style="width: 100px;">타임아웃</div>
<div class="header-cell col-chart" style="width: 180px;">통계</div>
</div>
<!-- Table Body -->
<div class="board-table-body" id="statisticsTableBody">
<div class="board-table-row" th:each="detail : ${details}">
<div class="row-cell col-name" style="flex: 2;">
<span class="api-icon">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2" />
</svg>
</span><span class="api-name-text" th:text="${detail.apiName}">API 이름</span>
</td>
<td class="col-num" th:text="${#numbers.formatInteger(detail.totalCount, 0, 'COMMA')}">0</td>
<td class="col-num" th:text="${#numbers.formatInteger(detail.successCount, 0, 'COMMA')}">0</td>
<td class="col-num" th:text="${#numbers.formatInteger(detail.errorCount, 0, 'COMMA')}">0</td>
<td class="col-num" th:text="${#numbers.formatInteger(detail.timeoutCount, 0, 'COMMA')}">0</td>
<td class="col-chart">
<div class="stats-progress">
<div class="progress-success" th:style="'width:' + ${detail.successRate} + '%; background: #4A90D9;'"></div>
<div class="progress-timeout" th:style="'width:' + ${detail.timeoutRate} + '%; background: #F5C36B;'"></div>
<div class="progress-failure" th:style="'width:' + ${detail.errorRate} + '%; background: #F5A3B5;'"></div>
</span>
<span class="api-name-text" th:text="${detail.apiName}">API 이름</span>
</div>
</td>
</tr>
<tr th:if="${details == null or details.isEmpty()}" class="stats-empty">
<td colspan="6">데이터가 없습니다.</td>
</tr>
</tbody>
<tfoot id="statisticsTableFoot">
<tr class="total-row" th:if="${details != null and !details.isEmpty()}">
<td class="col-name">
<div class="row-cell col-num" style="width: 100px;" data-label="API호출수"
th:text="${#numbers.formatInteger(detail.totalCount, 0, 'COMMA')}">0</div>
<div class="row-cell col-num" style="width: 100px;" data-label="성공"
th:text="${#numbers.formatInteger(detail.successCount, 0, 'COMMA')}">0</div>
<div class="row-cell col-num" style="width: 100px;" data-label="실패"
th:text="${#numbers.formatInteger(detail.errorCount, 0, 'COMMA')}">0</div>
<div class="row-cell col-num" style="width: 100px;" data-label="타임아웃"
th:text="${#numbers.formatInteger(detail.timeoutCount, 0, 'COMMA')}">0</div>
<div class="row-cell col-chart" style="width: 180px;" data-label="통계">
<div class="stats-progress">
<div class="progress-success"
th:style="'width:' + ${detail.successRate} + '%; background: #4A90D9;'"></div>
<div class="progress-timeout"
th:style="'width:' + ${detail.timeoutRate} + '%; background: #F5C36B;'"></div>
<div class="progress-failure"
th:style="'width:' + ${detail.errorRate} + '%; background: #F5A3B5;'">
</div>
</div>
</div>
</div>
<div th:if="${details == null or details.isEmpty()}" class="stats-empty">
데이터가 없습니다.
</div>
</div>
<!-- Table Footer -->
<div class="board-table-body" id="statisticsTableFoot">
<div class="board-table-row total-row" th:if="${details != null and !details.isEmpty()}">
<div class="row-cell col-name" style="flex: 2;">
<span class="api-icon total">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2">
<path d="M3 3v18h18" />
<path d="M18 9l-5 5-4-4-4 4" />
</svg>
</span><span class="api-name-text">전체</span>
</td>
<td class="col-num" th:text="${#numbers.formatInteger(summary.totalCount, 0, 'COMMA')}">0</td>
<td class="col-num" th:text="${#numbers.formatInteger(summary.successCount, 0, 'COMMA')}">0</td>
<td class="col-num" th:text="${#numbers.formatInteger(summary.errorCount, 0, 'COMMA')}">0</td>
<td class="col-num" th:text="${#numbers.formatInteger(summary.timeoutCount, 0, 'COMMA')}">0</td>
<td class="col-chart">
<div class="stats-progress">
<div class="progress-success" th:style="'width:' + ${summary.successRate} + '%; background: #4A90D9;'"></div>
<div class="progress-timeout" th:style="'width:' + ${summary.timeoutRate} + '%; background: #F5C36B;'"></div>
<div class="progress-failure" th:style="'width:' + ${summary.errorRate} + '%; background: #F5A3B5;'"></div>
</span>
<span class="api-name-text">전체</span>
</div>
<div class="row-cell col-num" style="width: 100px;" data-label="전체 API호출수"
th:text="${#numbers.formatInteger(summary.totalCount, 0, 'COMMA')}">0</div>
<div class="row-cell col-num" style="width: 100px;" data-label="전체 성공"
th:text="${#numbers.formatInteger(summary.successCount, 0, 'COMMA')}">0</div>
<div class="row-cell col-num" style="width: 100px;" data-label="전체 실패"
th:text="${#numbers.formatInteger(summary.errorCount, 0, 'COMMA')}">0</div>
<div class="row-cell col-num" style="width: 100px;" data-label="전체 타임아웃"
th:text="${#numbers.formatInteger(summary.timeoutCount, 0, 'COMMA')}">0</div>
<div class="row-cell col-chart" style="width: 180px;" data-label="전체 통계">
<div class="stats-progress">
<div class="progress-success"
th:style="'width:' + ${summary.successRate} + '%; background: #4A90D9;'"></div>
<div class="progress-timeout"
th:style="'width:' + ${summary.timeoutRate} + '%; background: #F5C36B;'"></div>
<div class="progress-failure"
th:style="'width:' + ${summary.errorRate} + '%; background: #F5A3B5;'"></div>
</div>
</div>
</div>
</div>
</div>
</td>
</tr>
</tfoot>
</table>
</div>
<!-- 기간별(일별/월별) 누적 통계 Table Section -->
<div class="statistics-table-section" style="margin-top: 40px;">
<div class="table-controls" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; background: none; border: none; padding: 0;">
<h3 class="table-title" style="font-size: 20px; font-weight: 700; color: #1e293b; display: flex; align-items: center; gap: 8px; margin: 0;">
<div class="table-controls">
<h3 class="table-title">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="4" width="18" height="18" rx="2" />
<path d="M16 2v4M8 2v4M3 10h18" />
@@ -330,31 +276,34 @@
</h3>
</div>
<table class="stats-table stats-table--period">
<thead>
<tr>
<th id="periodLabel">일자</th>
<th>API호출수</th>
<th>성공</th>
<th>실패</th>
<th>타임아웃</th>
</tr>
</thead>
<tbody id="periodTableBody">
<tr th:each="p : ${periods}">
<td th:text="${p.period}">2026-07-15 (수)</td>
<td th:text="${p.hasData ? #numbers.formatInteger(p.totalCount, 0, 'COMMA') : '-'}">0</td>
<td th:text="${p.hasData ? #numbers.formatInteger(p.successCount, 0, 'COMMA') : '-'}">0</td>
<td th:text="${p.hasData ? #numbers.formatInteger(p.errorCount, 0, 'COMMA') : '-'}">0</td>
<td th:text="${p.hasData ? #numbers.formatInteger(p.timeoutCount, 0, 'COMMA') : '-'}">0</td>
</tr>
<tr th:if="${periods == null or periods.isEmpty()}" class="stats-empty">
<td colspan="5">데이터가 없습니다.</td>
</tr>
</tbody>
</table>
<div class="board-table">
<!-- Table Header -->
<div class="board-table-header">
<div class="header-cell" id="periodLabel" style="flex: 1;">일자</div>
<div class="header-cell" style="flex: 1;">API호출수</div>
<div class="header-cell" style="flex: 1;">성공</div>
<div class="header-cell" style="flex: 1;">실패</div>
<div class="header-cell" style="flex: 1;">타임아웃</div>
</div>
<!-- Table Body -->
<div class="board-table-body" id="periodTableBody">
<div class="board-table-row" th:each="p : ${periods}">
<div class="row-cell" style="flex: 1;" data-label="일자" th:text="${p.period}">2026-07-15 (수)</div>
<div class="row-cell" style="flex: 1;" data-label="API호출수"
th:text="${p.hasData ? #numbers.formatInteger(p.totalCount, 0, 'COMMA') : '-'}">0</div>
<div class="row-cell" style="flex: 1;" data-label="성공"
th:text="${p.hasData ? #numbers.formatInteger(p.successCount, 0, 'COMMA') : '-'}">0</div>
<div class="row-cell" style="flex: 1;" data-label="실패"
th:text="${p.hasData ? #numbers.formatInteger(p.errorCount, 0, 'COMMA') : '-'}">0</div>
<div class="row-cell" style="flex: 1;" data-label="타임아웃"
th:text="${p.hasData ? #numbers.formatInteger(p.timeoutCount, 0, 'COMMA') : '-'}">0</div>
</div>
<div th:if="${periods == null or periods.isEmpty()}" class="stats-empty">
데이터가 없습니다.
</div>
</div>
</div>
</div>
</section>
</div>
</div>
@@ -417,30 +366,30 @@
if (currentData.details && currentData.details.length > 0) {
currentData.details.forEach(function (d) {
tbody.append(`
<tr>
<td class="col-name">
<div class="board-table-row">
<div class="row-cell col-name" style="flex: 2;">
<span class="api-icon">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2"/>
</svg>
</span><span class="api-name-text">${d.apiName || '-'}</span>
</td>
<td class="col-num">${numberWithCommas(d.totalCount)}</td>
<td class="col-num">${numberWithCommas(d.successCount)}</td>
<td class="col-num">${numberWithCommas(d.errorCount)}</td>
<td class="col-num">${numberWithCommas(d.timeoutCount)}</td>
<td class="col-chart">
</div>
<div class="row-cell col-num" style="width: 100px;" data-label="API호출수">${numberWithCommas(d.totalCount)}</div>
<div class="row-cell col-num" style="width: 100px;" data-label="성공">${numberWithCommas(d.successCount)}</div>
<div class="row-cell col-num" style="width: 100px;" data-label="실패">${numberWithCommas(d.errorCount)}</div>
<div class="row-cell col-num" style="width: 100px;" data-label="타임아웃">${numberWithCommas(d.timeoutCount)}</div>
<div class="row-cell col-chart" style="width: 180px;" data-label="통계">
<div class="stats-progress">
<div class="progress-success" style="width:${d.successRate}%; background: #4A90D9;"></div>
<div class="progress-timeout" style="width:${d.timeoutRate}%; background: #F5C36B;"></div>
<div class="progress-failure" style="width:${d.errorRate}%; background: #F5A3B5;"></div>
</div>
</td>
</tr>`);
</div>
</div>`);
});
renderTotalFooter();
} else {
tbody.html('<tr class="stats-empty"><td colspan="6">데이터가 없습니다.</td></tr>');
tbody.html('<div class="stats-empty">데이터가 없습니다.</div>');
$('#statisticsTableFoot').empty();
}
}
@@ -448,26 +397,26 @@
function renderTotalFooter() {
var sm = currentData.summary;
$('#statisticsTableFoot').html(`
<tr class="total-row">
<td class="col-name">
<div class="board-table-row total-row">
<div class="row-cell col-name" style="flex: 2;">
<span class="api-icon total">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 3v18h18"/><path d="M18 9l-5 5-4-4-4 4"/>
</svg>
</span><span class="api-name-text">전체</span>
</td>
<td class="col-num">${numberWithCommas(sm.totalCount)}</td>
<td class="col-num">${numberWithCommas(sm.successCount)}</td>
<td class="col-num">${numberWithCommas(sm.errorCount)}</td>
<td class="col-num">${numberWithCommas(sm.timeoutCount)}</td>
<td class="col-chart">
</div>
<div class="row-cell col-num" style="width: 100px;" data-label="전체 API호출수">${numberWithCommas(sm.totalCount)}</div>
<div class="row-cell col-num" style="width: 100px;" data-label="전체 성공">${numberWithCommas(sm.successCount)}</div>
<div class="row-cell col-num" style="width: 100px;" data-label="전체 실패">${numberWithCommas(sm.errorCount)}</div>
<div class="row-cell col-num" style="width: 100px;" data-label="전체 타임아웃">${numberWithCommas(sm.timeoutCount)}</div>
<div class="row-cell col-chart" style="width: 180px;" data-label="전체 통계">
<div class="stats-progress">
<div class="progress-success" style="width:${sm.successRate}%; background: #4A90D9;"></div>
<div class="progress-timeout" style="width:${sm.timeoutRate}%; background: #F5C36B;"></div>
<div class="progress-failure" style="width:${sm.errorRate}%; background: #F5A3B5;"></div>
</div>
</td>
</tr>`);
</div>
</div>`);
}
// ── 기간별(일/월) 표 (table DOM) ──
@@ -477,16 +426,16 @@
if (currentData.periods && currentData.periods.length > 0) {
currentData.periods.forEach(function (p) {
tbody.append(`
<tr>
<td>${p.period || '-'}</td>
<td>${p.hasData ? numberWithCommas(p.totalCount) : '-'}</td>
<td>${p.hasData ? numberWithCommas(p.successCount) : '-'}</td>
<td>${p.hasData ? numberWithCommas(p.errorCount) : '-'}</td>
<td>${p.hasData ? numberWithCommas(p.timeoutCount) : '-'}</td>
</tr>`);
<div class="board-table-row">
<div class="row-cell" style="flex: 1;" data-label="일자">${p.period || '-'}</div>
<div class="row-cell" style="flex: 1;" data-label="API호출수">${p.hasData ? numberWithCommas(p.totalCount) : '-'}</div>
<div class="row-cell" style="flex: 1;" data-label="성공">${p.hasData ? numberWithCommas(p.successCount) : '-'}</div>
<div class="row-cell" style="flex: 1;" data-label="실패">${p.hasData ? numberWithCommas(p.errorCount) : '-'}</div>
<div class="row-cell" style="flex: 1;" data-label="타임아웃">${p.hasData ? numberWithCommas(p.timeoutCount) : '-'}</div>
</div>`);
});
} else {
tbody.html('<tr class="stats-empty"><td colspan="5">데이터가 없습니다.</td></tr>');
tbody.html('<div class="stats-empty">데이터가 없습니다.</div>');
}
}
@@ -623,4 +572,5 @@
</th:block>
</body>
</html>
@@ -1,5 +1,6 @@
<!-- 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 -->
<div class="modal-backdrop" id="withdrawalModalBackdrop"></div>
@@ -24,15 +25,25 @@
<li>회원 탈퇴는 API Portal 사이트의 회원 탈퇴입니다.</li>
<li>사용중인 API 서비스의 중지는 제휴 담당자를 통해 문의 주시기 바랍니다.</li>
</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;">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/>
<input type="hidden" name="withdrawReason" id="withdrawalReasonHidden"/>
</form>
</div>
<!-- Modal Footer -->
<div class="modal-footer">
<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>