merge 충돌 해결

This commit is contained in:
hong
2026-07-30 10:07:00 +09:00
52 changed files with 5300 additions and 578 deletions
@@ -64,6 +64,12 @@ public class ApiController {
Map<String, Object> searchResult = apiSearchFacade.searchApis(new ApiGroupSearch());
// 상세 타이틀에 노출할 현재 API의 그룹명 세팅(selectDetail은 apiGroupName을 채우지 않음)
ApiServiceDTO apiGroup = apiServiceService.findApiServiceByApiId(id);
if (apiGroup != null) {
api.setApiGroupName(apiGroup.getGroupName());
}
model.addAttribute("apiSpecInfo", api);
model.addAttribute("totalApiCount", searchResult.get("totalApiCount"));
model.addAttribute("services", searchResult.get("services"));
@@ -1,5 +1,6 @@
package com.eactive.apim.portal.apps.apis.filter;
import com.eactive.apim.portal.common.util.StringMaskingUtil;
import com.eactive.apim.portal.djb.testbed.config.DjbTestbedGatewayProperty;
import java.io.BufferedReader;
import java.io.DataOutputStream;
@@ -37,13 +38,18 @@ public class APISender {
public String requestPost(String uri, String requestBody) throws IOException {
if (logger.isDebugEnabled()) {
logger.debug("APISender POST(json) 요청 - uri={}, bodyLen={}, body={}",
uri, requestBody == null ? 0 : requestBody.length(), StringMaskingUtil.maskFormBody(requestBody));
}
HttpURLConnection connection = getHttpURLConnection(uri, requestBody);
String response = getResponse(connection);
connection.disconnect();
if (logger.isDebugEnabled()) {
logger.debug(response);
logger.debug("APISender POST(json) 응답 - uri={}, response={}", uri, response);
}
return response;
}
@@ -85,11 +91,15 @@ public class APISender {
}
connection.setDoOutput(true);
if (logger.isDebugEnabled()) {
logger.debug("APISender GET 요청 - uri={}", appendUriAndParams(uri, params));
}
String response = getResponse(connection);
connection.disconnect();
if (logger.isDebugEnabled()) {
logger.debug(response);
logger.debug("APISender GET 응답 - uri={}, response={}", uri, response);
}
return response;
}
@@ -110,6 +120,12 @@ public class APISender {
}
connection.setDoOutput(true);
if (logger.isDebugEnabled()) {
// client_secret 등 민감 파라미터는 마스킹. body 비어있으면 상위에서 본문 전송 유실.
logger.debug("APISender POST 요청 - uri={}, bodyLen={}, body={}",
uri, requestBody == null ? 0 : requestBody.length(), StringMaskingUtil.maskFormBody(requestBody));
}
try (DataOutputStream outputStream = new DataOutputStream(connection.getOutputStream())) {
byte[] requestBodyBytes = requestBody.getBytes(StandardCharsets.UTF_8);
outputStream.write(requestBodyBytes);
@@ -120,7 +136,7 @@ public class APISender {
connection.disconnect();
if (logger.isDebugEnabled()) {
logger.debug(response);
logger.debug("APISender POST 응답 - uri={}, response={}", uri, response);
}
return response;
}
@@ -4,12 +4,13 @@ package com.eactive.apim.portal.apps.apis.filter;
import com.eactive.apim.portal.apps.apis.dto.ApiSpecInfoDto;
import com.eactive.apim.portal.apps.apis.service.ApiService;
import com.eactive.apim.portal.common.util.ApplicationContextUtil;
import com.eactive.apim.portal.common.util.StringMaskingUtil;
import com.eactive.apim.portal.djb.testbed.config.DjbTestbedGatewayProperty;
import com.eactive.apim.portal.djb.testbed.enums.DjbGatewayMode;
import java.io.BufferedReader;
import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URLEncoder;
import java.util.Enumeration;
import java.util.HashMap;
import java.util.Map;
@@ -72,6 +73,8 @@ public class ApiTesterFilter implements Filter {
String auditId = ApiTesterAuditLogger.newAuditId();
long auditStart = System.currentTimeMillis();
String auditType = "-";
// 실제 프록시 호출 대상 URL (mock 은 mockUrl, 토큰 GW 는 base-url+token-path 로 original-url 과 다를 수 있음) — 오류 로그용
String proxyTarget = null;
// original-url 헤더가 없으면 프록시 대상을 알 수 없음 → 400 (NPE 방지)
if (url == null || url.trim().isEmpty()) {
@@ -86,22 +89,24 @@ public class ApiTesterFilter implements Filter {
// 반환하기 위해 try 로 감싼다.
try {
// 게이트웨이 모드에 따라 OAuth 토큰 발급 요청을 mock 또는 실 게이트웨이 forward 로 분기 (DJPGPT0001)
// 토큰 발급 분기는 전역 게이트웨이 모드가 아니라 "요청 URL 경로"로 판단한다 (API 별 responseType 기반).
// - mock API → 프론트가 포탈 mock 토큰 경로(/api/v1/oauth/token)로 요청 → 즉시 mock 토큰 발급
// - gw API → 프론트가 실 GW 토큰 경로(token-path)로 요청 → 실 게이트웨이 forward
DjbTestbedGatewayProperty gatewayProperty = ApplicationContextUtil.getContext().getBean(DjbTestbedGatewayProperty.class);
DjbGatewayMode gatewayMode = gatewayProperty.resolveGatewayMode();
boolean tokenRequest = url.contains(DjbTestbedGatewayProperty.PORTAL_MOCK_TOKEN_PATH)
|| url.contains(gatewayProperty.tokenPath());
boolean mockTokenRequest = url.contains(DjbTestbedGatewayProperty.PORTAL_MOCK_TOKEN_PATH);
boolean tokenRequest = mockTokenRequest || url.contains(gatewayProperty.tokenPath());
// 본문은 한 번만 읽어 프록시 forward 와 감사 로그에 함께 사용 (GET 이면 빈 문자열)
String requestBody = readBody(httpServletRequest);
ApiTesterAuditLogger.logRequest(auditId, httpServletRequest, url, gatewayMode.name(), tokenRequest, requestBody);
ApiTesterAuditLogger.logRequest(auditId, httpServletRequest, url,
mockTokenRequest ? "MOCK_TOKEN" : "GW", tokenRequest, requestBody);
if (tokenRequest) {
String body = requestBody;
if (gatewayMode == DjbGatewayMode.PORTAL_MOCK) {
if (mockTokenRequest) {
auditType = "TOKEN_MOCK";
// PortalMock: 고정 mock 토큰 반환 (기존 동작 유지)
// mock 응답유형 API: 고정 mock 토큰 즉시 발급 (Secret 검증 없음)
Map<String, String> params = new HashMap<>();
String[] pairs = body.split("&");
for (String pair : pairs) {
@@ -130,6 +135,12 @@ public class ApiTesterFilter implements Filter {
headers.put("Content-Type", "application/x-www-form-urlencoded");
headers.put("Accept", "application/json");
String target = gatewayProperty.baseUrl() + gatewayProperty.tokenPath();
proxyTarget = target;
if (logger.isDebugEnabled()) {
// client_secret 은 마스킹. body 가 비면 프론트→프록시 전송 유실, client_id 없으면 GW "client not found" 원인.
logger.debug("TOKEN_GW forward - auditId={}, target={}, bodyLen={}, body={}",
auditId, target, body.length(), StringMaskingUtil.maskFormBody(body));
}
String tokenResponse = apiSender.requestPost(target, headers, new HashMap<>(), body);
response.setContentType("application/json");
@@ -191,6 +202,15 @@ public class ApiTesterFilter implements Filter {
paramMap = extractQueryParams(url);
}
proxyTarget = targetUri;
if (logger.isDebugEnabled()) {
// GW SERVICE_NOT_FOUND(어댑터 URI 미등록)·AUTH_FAIL 진단용:
// 스펙 식별/응답유형, 실제 forward 대상, 전달 헤더(민감값 마스킹), 본문 길이를 남긴다.
logger.debug("{} forward - auditId={}, apiId={}, apiUrl={}, apiMethod={}, responseType={}, originalUrl={}, target={}, bodyLen={}, headers={}",
auditType, auditId, apiSpecInfoDto.getApiId(), apiSpecInfoDto.getApiUrl(),
apiSpecInfoDto.getApiMethod(), responseType, url, targetUri,
requestBody == null ? 0 : requestBody.length(), maskHeaders(headers));
}
APISender apiSender = ApplicationContextUtil.getContext().getBean(APISender.class);
String responseStr;
if ("post".equalsIgnoreCase(apiSpecInfoDto.getApiMethod())) {
@@ -198,23 +218,34 @@ public class ApiTesterFilter implements Filter {
} else {
responseStr = apiSender.requestGet(targetUri, headers, paramMap);
}
if (logger.isDebugEnabled()) {
logger.debug("{} response - auditId={}, target={}, respLen={}, preview={}",
auditType, auditId, targetUri,
responseStr == null ? 0 : responseStr.length(), previewOf(responseStr));
}
response.setContentType("application/json");
response.getWriter().println(responseStr);
}
} catch (java.net.SocketTimeoutException e) {
// 연결/응답 타임아웃 (djb.gateway.timeout 초과)
logger.warn("테스트베드 프록시 타임아웃: {}", e.getMessage());
logger.warn("테스트베드 프록시 타임아웃 - auditId={}, type={}, method={}, originalUrl={}, proxyTarget={}, elapsedMs={}, cause={}: {}",
auditId, auditType, httpServletRequest.getMethod(), url, proxyTarget,
System.currentTimeMillis() - auditStart, e.getClass().getSimpleName(), e.getMessage());
writeJson(response, HttpServletResponse.SC_GATEWAY_TIMEOUT,
"{\"error\":\"게이트웨이 응답 시간 초과(timeout)\",\"detail\":\"" + escapeJson(e.getMessage()) + "\"}");
} catch (IOException e) {
// 연결 실패 등 네트워크 오류
logger.error("테스트베드 프록시 호출 실패", e);
// 연결 실패 등 네트워크 오류 (ConnectException: 대상 다운/포트 닫힘, UnknownHostException: 주소 오기입 등)
logger.error("테스트베드 프록시 호출 실패 - auditId={}, type={}, method={}, originalUrl={}, proxyTarget={}, elapsedMs={}, cause={}: {}",
auditId, auditType, httpServletRequest.getMethod(), url, proxyTarget,
System.currentTimeMillis() - auditStart, e.getClass().getSimpleName(), e.getMessage(), e);
writeJson(response, HttpServletResponse.SC_BAD_GATEWAY,
"{\"error\":\"게이트웨이 호출 실패\",\"detail\":\"" + escapeJson(e.getMessage()) + "\"}");
"{\"error\":\"게이트웨이 호출 실패\",\"detail\":\"" + escapeJson(e.getClass().getSimpleName() + ": " + e.getMessage()) + "\"}");
} catch (Exception e) {
// 그 외 예기치 못한 오류도 JSON 으로 반환
logger.error("테스트베드 프록시 처리 오류", e);
logger.error("테스트베드 프록시 처리 오류 - auditId={}, type={}, method={}, originalUrl={}, proxyTarget={}, elapsedMs={}, cause={}: {}",
auditId, auditType, httpServletRequest.getMethod(), url, proxyTarget,
System.currentTimeMillis() - auditStart, e.getClass().getSimpleName(), e.getMessage(), e);
writeJson(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR,
"{\"error\":\"요청 처리 중 오류\",\"detail\":\"" + escapeJson(e.getMessage()) + "\"}");
} finally {
@@ -223,7 +254,14 @@ public class ApiTesterFilter implements Filter {
}
}
/** 요청 본문 전체를 문자열로 읽는다. */
/**
* 요청 본문 전체를 문자열로 읽는다.
*
* <p>form-urlencoded 요청에서 상위 필터(XSS/CSRF/Multipart 등)가 이미 {@code getParameter*} 로
* 본문 스트림을 소비했으면 {@code getReader()} 는 빈 문자열을 반환한다. 이 경우 토큰 발급 본문
* (grant_type/client_id/client_secret/scope)이 게이트웨이로 전달되지 않아 "client not found" 로
* 실패하므로, 파싱된 파라미터 맵으로 본문을 재구성해 복원한다.</p>
*/
private String readBody(HttpServletRequest request) throws IOException {
StringBuilder sb = new StringBuilder();
BufferedReader reader = request.getReader();
@@ -231,6 +269,39 @@ public class ApiTesterFilter implements Filter {
while ((line = reader.readLine()) != null) {
sb.append(line);
}
if (sb.length() == 0 && isFormUrlEncoded(request)) {
String rebuilt = rebuildFormBodyFromParams(request);
if (!rebuilt.isEmpty()) {
logger.debug("요청 본문이 비어 파라미터 맵으로 재구성 - body={}", StringMaskingUtil.maskFormBody(rebuilt));
return rebuilt;
}
}
return sb.toString();
}
/** Content-Type 이 application/x-www-form-urlencoded 계열인지. */
private boolean isFormUrlEncoded(HttpServletRequest request) {
String contentType = request.getContentType();
return contentType != null && contentType.toLowerCase().contains("application/x-www-form-urlencoded");
}
/** 파싱된 파라미터 맵을 form-urlencoded 본문 문자열로 재구성 (본문 스트림이 이미 소비된 경우 복원용). */
private String rebuildFormBodyFromParams(HttpServletRequest request) {
StringBuilder sb = new StringBuilder();
for (Map.Entry<String, String[]> entry : request.getParameterMap().entrySet()) {
for (String value : entry.getValue()) {
if (sb.length() > 0) {
sb.append('&');
}
try {
sb.append(URLEncoder.encode(entry.getKey(), "UTF-8"))
.append('=')
.append(URLEncoder.encode(value == null ? "" : value, "UTF-8"));
} catch (java.io.UnsupportedEncodingException e) {
sb.append(entry.getKey()).append('=').append(value == null ? "" : value);
}
}
}
return sb.toString();
}
@@ -250,6 +321,27 @@ public class ApiTesterFilter implements Filter {
}
/** 상태코드 + JSON 본문 응답. */
/** forward 헤더 debug 출력용 — 민감 헤더(토큰/쿠키 등)는 StringMaskingUtil 로 마스킹. */
private String maskHeaders(Map<String, String> headers) {
StringBuilder sb = new StringBuilder("{");
for (Map.Entry<String, String> e : headers.entrySet()) {
if (sb.length() > 1) {
sb.append(", ");
}
sb.append(e.getKey()).append(':').append(StringMaskingUtil.maskHeaderValue(e.getKey(), e.getValue()));
}
return sb.append('}').toString();
}
/** 응답 body debug 프리뷰 — 앞 300자까지만 (개행 제거). */
private String previewOf(String body) {
if (body == null) {
return "null";
}
String flat = body.replaceAll("\\s+", " ").trim();
return flat.length() > 300 ? flat.substring(0, 300) + "" : flat;
}
private void writeJson(ServletResponse response, int status, String json) throws IOException {
((HttpServletResponse) response).setStatus(status);
response.setContentType("application/json");
@@ -52,7 +52,7 @@ import org.springframework.web.servlet.mvc.support.RedirectAttributes;
@Slf4j
@Controller
@RequestMapping("/myapikey")
@RequestMapping("/clients")
@RequiredArgsConstructor
@SessionAttributes({"apiKeyRegistration", "apiKeyModification"})
public class MyAppController {
@@ -118,14 +118,14 @@ public class MyAppController {
@Secured("ROLE_APP")
public ModelAndView appRequestDetail(@RequestParam(value = "id", required = false) String id, Model model) {
if (id == null) {
return new ModelAndView("redirect:/myapikey");
return new ModelAndView("redirect:/clients");
}
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
AppRequestDTO appRequest = appServiceFacade.getAppRequestById(id, user.getPortalOrg());
if (appRequest == null) {
return new ModelAndView("redirect:/myapikey");
return new ModelAndView("redirect:/clients");
}
// API 목록 조회 및 설정
@@ -156,14 +156,14 @@ public class MyAppController {
@Secured("ROLE_APP")
public ModelAndView credentialDetail(@RequestParam(value = "id", required = false) String id, Model model) {
if (id == null) {
return new ModelAndView("redirect:/myapikey");
return new ModelAndView("redirect:/clients");
}
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
ClientDTO apiKey = appServiceFacade.getApiKey(user.getPortalOrg().getId(), id);
if (apiKey == null) {
return new ModelAndView("redirect:/myapikey");
return new ModelAndView("redirect:/clients");
}
// API 목록에 서비스 정보 추가
@@ -323,10 +323,12 @@ public class MyAppController {
}
/**
* Client Secret을 비밀번호 확인 후 1회 노출하고 즉시 DB에서 물리 삭제합니다.
* Client Secret을 1회 노출하고 즉시 DB에서 물리 삭제합니다.
* 본인 확인은 step-up 2FA({@link StepUpProtectedPaths#REVEAL_SECRET} 인터셉터 가드)가 담당하며,
* 통과권 없이 진입하면 401(stepUpRequired) 로 차단됩니다.
* 보안 정책상 비밀정보는 최초 1회만 제공됩니다.
*
* @param requestData clientId, password 포함
* @param requestData clientId 포함
* @return {success, secret} / {success:false, alreadyRevealed:true} / {success:false, message}
*/
@PostMapping("/credential/reveal-secret")
@@ -336,7 +338,6 @@ public class MyAppController {
Map<String, Object> result = new java.util.HashMap<>();
String clientId = requestData.get("clientId");
String password = requestData.get("password");
if (clientId == null || clientId.trim().isEmpty()) {
result.put("success", false);
@@ -346,14 +347,7 @@ public class MyAppController {
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
// 1. 본인 확인 (비밀번호)
if (!appServiceFacade.verifyUserPassword(user, password)) {
result.put("success", false);
result.put("message", "비밀번호가 일치하지 않습니다.");
return result;
}
// 2. 소유권 확인 + 1회 노출 + 물리 삭제
// 소유권 확인 + 1회 노출 + 물리 삭제 (본인 확인은 step-up 2FA 인터셉터가 선행)
try {
String secret = appServiceFacade.revealAndDeleteClientSecret(user.getPortalOrg().getId(), clientId);
if (secret == null) {
@@ -435,7 +429,7 @@ public class MyAppController {
@Secured("ROLE_API_KEY_REQUEST_VIEW")
public ModelAndView apiRequestDetail(@RequestParam(value = "id", required = false) String id, Model model) {
if (id == null) {
return new ModelAndView("redirect:/myapikey/api_key_request/history");
return new ModelAndView("redirect:/clients/api_key_request/history");
}
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
@@ -533,7 +527,7 @@ public class MyAppController {
registration.setIpWhitelistFromString(ipWhitelist);
}
return new ModelAndView("redirect:/myapikey/register/step2");
return new ModelAndView("redirect:/clients/register/step2");
} catch (Exception e) {
setupStepModel(model, 1);
@@ -598,7 +592,7 @@ public class MyAppController {
// 1단계가 완료되었는지 검증
if (!registration.isStep1Complete()) {
redirectAttributes.addFlashAttribute("error", "먼저 기본 정보를 입력해주세요.");
return new ModelAndView("redirect:/myapikey/register/step1");
return new ModelAndView("redirect:/clients/register/step1");
}
// 서비스 카테고리만 가져오기 (API는 AJAX로 로드됨)
@@ -629,7 +623,7 @@ public class MyAppController {
}
// 1단계로 리다이렉트
return new ModelAndView("redirect:/myapikey/register/step1");
return new ModelAndView("redirect:/clients/register/step1");
}
/**
@@ -647,7 +641,7 @@ public class MyAppController {
// 1단계가 완료되었는지 검증
if (!registration.isStep1Complete()) {
redirectAttributes.addFlashAttribute("error", "먼저 기본 정보를 입력해주세요.");
return new ModelAndView("redirect:/myapikey/register/step1");
return new ModelAndView("redirect:/clients/register/step1");
}
// API 선택은 선택 사항 — 미선택(빈 목록)도 허용한다.
@@ -656,7 +650,7 @@ public class MyAppController {
// 등록이 완료되었는지 최종 검증
if (!registration.isComplete()) {
redirectAttributes.addFlashAttribute("error", "등록 정보가 완전하지 않습니다.");
return new ModelAndView("redirect:/myapikey/register/step1");
return new ModelAndView("redirect:/clients/register/step1");
}
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
@@ -673,12 +667,12 @@ public class MyAppController {
// 성공적으로 완료된 후 세션 초기화
sessionStatus.setComplete();
return new ModelAndView("redirect:/myapikey/register/step3");
return new ModelAndView("redirect:/clients/register/step3");
} catch (Exception e) {
// 실패 시 에러 메시지와 함께 step2로 돌아감
redirectAttributes.addFlashAttribute("error", "API Key 등록 중 오류가 발생했습니다. 다시 시도해주세요.");
return new ModelAndView("redirect:/myapikey/register/step2");
return new ModelAndView("redirect:/clients/register/step2");
}
}
@@ -696,7 +690,7 @@ public class MyAppController {
// 직접 접근 방지: Step 2 POST를 거치지 않고 직접 접근한 경우
if (registrationSuccess == null || !registrationSuccess) {
return new ModelAndView("redirect:/myapikey");
return new ModelAndView("redirect:/clients");
}
// 결과 페이지 표시용 속성 설정
@@ -715,7 +709,7 @@ public class MyAppController {
public String cancelRegistration(SessionStatus sessionStatus) {
// 등록과 관련된 세션 데이터 초기화
sessionStatus.setComplete();
return "redirect:/myapikey";
return "redirect:/clients";
}
/**
@@ -759,13 +753,15 @@ public class MyAppController {
@Secured("ROLE_API_KEY_REQUEST")
public ModelAndView modifyStep1(
@RequestParam(value = "clientId", required = false) String clientId,
@RequestParam(value = "goto", required = false) String gotoStep,
@RequestParam(value = "apiApplyToast", required = false) String apiApplyToast,
@ModelAttribute("apiKeyModification") ApiKeyRegistrationDTO modification,
SessionStatus sessionStatus,
Model model,
RedirectAttributes redirectAttributes) {
if (clientId == null) {
return new ModelAndView("redirect:/myapikey");
return new ModelAndView("redirect:/clients");
}
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
@@ -773,7 +769,7 @@ public class MyAppController {
// 기존 API Key 정보 조회
ClientDTO apiKey = appServiceFacade.getApiKey(user.getPortalOrg().getId(), clientId);
if (apiKey == null) {
return new ModelAndView("redirect:/myapikey");
return new ModelAndView("redirect:/clients");
}
// 새로운 수정 세션 시작시에만 초기화
@@ -815,6 +811,16 @@ public class MyAppController {
model.addAttribute("apiKeyModification", modification);
}
// API 신청 절차: 클라이언트 1건 보유 시 API 상세에서 goto=apis 로 진입
// → 세션 초기화 후 API 선택(2단계)로 직행 (기본 정보 미완성이면 step2 가드가 1단계로 되돌림)
if ("apis".equals(gotoStep)) {
String redirectUrl = "redirect:/clients/modify/step2";
if (apiApplyToast != null && apiApplyToast.matches("[a-zA-Z_-]{1,30}")) {
redirectUrl += "?apiApplyToast=" + apiApplyToast;
}
return new ModelAndView(redirectUrl);
}
// Step 모델 설정
setupStepModel(model, 1);
model.addAttribute("userOrg", user.getPortalOrg());
@@ -866,7 +872,7 @@ public class MyAppController {
modification.setIpWhitelistFromString(ipWhitelist);
}
return new ModelAndView("redirect:/myapikey/modify/step2");
return new ModelAndView("redirect:/clients/modify/step2");
} catch (Exception e) {
setupStepModel(model, 1);
@@ -888,7 +894,7 @@ public class MyAppController {
// 1단계가 완료되었는지 검증
if (!modification.isStep1Complete()) {
redirectAttributes.addFlashAttribute("error", "먼저 기본 정보를 입력해주세요.");
return new ModelAndView("redirect:/myapikey/modify/step1?clientId=" + modification.getClientId());
return new ModelAndView("redirect:/clients/modify/step1?clientId=" + modification.getClientId());
}
// 서비스 카테고리와 API 목록 가져오기
@@ -921,7 +927,7 @@ public class MyAppController {
}
// 1단계로 리다이렉트
return new ModelAndView("redirect:/myapikey/modify/step1?clientId=" + modification.getClientId());
return new ModelAndView("redirect:/clients/modify/step1?clientId=" + modification.getClientId());
}
/**
@@ -940,13 +946,13 @@ public class MyAppController {
// 1단계가 완료되었는지 검증
if (!modification.isStep1Complete()) {
redirectAttributes.addFlashAttribute("error", "먼저 기본 정보를 입력해주세요.");
return new ModelAndView("redirect:/myapikey/modify/step1?clientId=" + modification.getClientId());
return new ModelAndView("redirect:/clients/modify/step1?clientId=" + modification.getClientId());
}
// API 선택 검증
if (selectedApis == null || selectedApis.isEmpty()) {
redirectAttributes.addFlashAttribute("error", "최소 1개 이상의 API를 선택해주세요.");
return new ModelAndView("redirect:/myapikey/modify/step2");
return new ModelAndView("redirect:/clients/modify/step2");
}
// 선택된 API를 세션에 저장
@@ -955,7 +961,7 @@ public class MyAppController {
// 등록이 완료되었는지 최종 검증
if (!modification.isComplete()) {
redirectAttributes.addFlashAttribute("error", "수정 정보가 완전하지 않습니다.");
return new ModelAndView("redirect:/myapikey/modify/step1?clientId=" + modification.getClientId());
return new ModelAndView("redirect:/clients/modify/step1?clientId=" + modification.getClientId());
}
// 반영 직전 2FA: 통과권이 없으면 커밋하지 않고 step2 로 되돌린다(프론트가 먼저 2FA 팝업을 띄운다).
@@ -963,7 +969,7 @@ public class MyAppController {
if (isAppModifyTwofaRequired()
&& !twoFactorService.consumeStepUpPass(session, StepUpProtectedPaths.APP_MODIFY_COMMIT)) {
redirectAttributes.addFlashAttribute("error", "추가 인증(2FA) 후 다시 시도해 주세요.");
return new ModelAndView("redirect:/myapikey/modify/step2");
return new ModelAndView("redirect:/clients/modify/step2");
}
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
@@ -980,12 +986,12 @@ public class MyAppController {
// 성공적으로 완료된 후 세션 초기화
sessionStatus.setComplete();
return new ModelAndView("redirect:/myapikey/modify/step3");
return new ModelAndView("redirect:/clients/modify/step3");
} catch (Exception e) {
// 실패 시 에러 메시지와 함께 step2로 돌아감
redirectAttributes.addFlashAttribute("error", "API Key 수정 요청 중 오류가 발생했습니다. 다시 시도해주세요.");
return new ModelAndView("redirect:/myapikey/modify/step2");
return new ModelAndView("redirect:/clients/modify/step2");
}
}
@@ -1002,7 +1008,7 @@ public class MyAppController {
// 직접 접근 방지: Step 2 POST를 거치지 않고 직접 접근한 경우
if (modificationComplete == null || !modificationComplete) {
return new ModelAndView("redirect:/myapikey");
return new ModelAndView("redirect:/clients");
}
// 결과 페이지 표시용 속성 설정
@@ -1031,9 +1037,9 @@ public class MyAppController {
// clientId가 있으면 상세 페이지로, 없으면 목록으로
if (clientId != null && !clientId.isEmpty()) {
return "redirect:/myapikey/credential_detail?id=" + clientId;
return "redirect:/clients/credential_detail?id=" + clientId;
} else {
return "redirect:/myapikey";
return "redirect:/clients";
}
}
@@ -41,11 +41,11 @@ public final class StepUpProtectedPaths {
}
/** Secret 키 조회 (AJAX POST) */
public static final String REVEAL_SECRET = "/myapikey/credential/reveal-secret";
public static final String REVEAL_SECRET = "/clients/credential/reveal-secret";
/** 앱 해지 신청 (AJAX POST) */
public static final String APP_KEY_DELETE = "/myapikey/api_key_delete";
public static final String APP_KEY_DELETE = "/clients/api_key_delete";
/** 앱 정보 수정 최종 반영(commit, POST /modify/step2) — 반영 직전 2FA. 진입/중간 단계는 가드하지 않음 */
public static final String APP_MODIFY_COMMIT = "/myapikey/modify/step2";
public static final String APP_MODIFY_COMMIT = "/clients/modify/step2";
/** 개인정보 변경 페이지 진입 (GET, 정확 일치) — PASSWORD 레벨 */
public static final String MYPAGE = "/mypage";
/** 비밀번호 변경 반영(commit, POST) — 반영 직전 2FA. 진입(GET)은 가드하지 않음 */
@@ -1,6 +1,7 @@
package com.eactive.apim.portal.apps.auth.twofactor;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.RoleCode;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
@@ -20,6 +21,7 @@ public class TwoFactorProperties {
public static final String GROUP = "Portal";
public static final String KEY_LOGIN_ENABLED = "two-factor.login.enabled";
public static final String KEY_LOGIN_TARGET_ROLES = "two-factor.login.target-roles";
public static final String KEY_TTL_SECONDS = "two-factor.ttl.seconds";
public static final String KEY_ATTEMPT_LIMIT = "two-factor.attempt.limit";
public static final String KEY_TEST_NOTICE_ENABLED = "two-factor.test-notice.enabled";
@@ -32,6 +34,33 @@ public class TwoFactorProperties {
return parseBool(resolve(KEY_LOGIN_ENABLED, "false", "로그인 2차 인증 활성화 여부 (true/false)"));
}
/**
* 로그인 2FA 적용 대상 역할인지 여부.
* 프로퍼티 값: 쉼표 구분 RoleCode 목록(예: {@code ROLE_CORP_MANAGER,ROLE_CORP_USER})
* 또는 {@code ALL}(전체 대상). 기본값은 법인관리자만.
* 미기재 역할은 로그인 2FA 를 건너뛴다(전체 스위치 {@link #isLoginEnabled()}와 AND 동작).
*/
public boolean isLoginTargetRole(RoleCode roleCode) {
if (roleCode == null) {
roleCode = RoleCode.ROLE_USER;
}
String value = resolve(KEY_LOGIN_TARGET_ROLES, RoleCode.ROLE_CORP_MANAGER.name(),
"로그인 2차 인증 대상 역할 (쉼표구분: ROLE_USER,ROLE_CORP_USER,ROLE_CORP_MANAGER / 전체: ALL)");
if (value == null || value.trim().isEmpty()) {
return false;
}
String trimmed = value.trim();
if ("ALL".equalsIgnoreCase(trimmed)) {
return true;
}
for (String token : trimmed.split(",")) {
if (roleCode.name().equalsIgnoreCase(token.trim())) {
return true;
}
}
return false;
}
/** step-up(민감기능) 2FA 전체 활성화 여부(마스터 스위치) */
public boolean isStepUpEnabled() {
return parseBool(resolve(KEY_STEPUP_ENABLED, "false", "민감기능 추가 인증(step-up) 전체 활성화 여부 (true/false)"));
@@ -118,6 +118,14 @@ public class LoginFinalizer {
}
}
// 법인 사용자(관리자/개발자) 로그인 시, 홈 최초 진입에서 클라이언트 신규 신청 유도 팝업을 1회 노출하도록 마킹한다.
// 실제 클라이언트(승인+요청중) 보유 여부 판정과 1회 소비는 IndexController 가 담당한다.
PortalUserEnums.RoleCode roleCode = user.getRoleCode();
if (roleCode == PortalUserEnums.RoleCode.ROLE_CORP_MANAGER
|| roleCode == PortalUserEnums.RoleCode.ROLE_CORP_USER) {
session.setAttribute("checkClientRegister", true);
}
// 중복 로그인 방지: 기존 세션 강제 로그아웃 + 현재 세션 등록
String clientIp = HttpRequestUtil.getClientIpAddress(request);
userSessionService.forceLogoutOtherSessions(normalizedUsername, sessionId);
@@ -1,10 +1,14 @@
package com.eactive.apim.portal.apps.main.controller;
import com.eactive.apim.portal.apps.apiservice.dto.ApiServiceDTO;
import com.eactive.apim.portal.apps.app.service.AppServiceFacade;
import com.eactive.apim.portal.apps.main.dto.IndexStatisticsDTO;
import com.eactive.apim.portal.apps.main.service.IndexStatisticsService;
import com.eactive.apim.portal.apps.main.service.MainApiFacade;
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.common.util.SecurityUtil;
import java.util.List;
import javax.servlet.http.HttpSession;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
@@ -17,6 +21,7 @@ public class IndexController {
private final MainApiFacade mainApiFacade;
private final IndexStatisticsService indexStatisticsService;
private final AppServiceFacade appServiceFacade;
/**
* 메인 페이지 API는 Portal Property 에 main.service.list 에 등록된 그룹을 기준으로 API를 조회함.
@@ -29,7 +34,7 @@ public class IndexController {
* @return
*/
@GetMapping("/")
public String index(Model model) {
public String index(Model model, HttpSession session) {
List<ApiServiceDTO> services = mainApiFacade.getOpenApiServices();
List<String> hashTags = mainApiFacade.getHashTags();
@@ -39,9 +44,37 @@ public class IndexController {
addAttributesToModel(model, services, hashTags, statistics);
resolveClientRegisterNudge(model, session);
return "apps/main/index";
}
/**
* 법인 사용자(관리자/개발자) 로그인 직후, 클라이언트(승인+요청중)가 하나도 없으면
* 클라이언트 신규 신청 유도 팝업 노출 플래그를 세팅한다.
*
* <p>노출 시점은 {@code LoginFinalizer} 가 로그인 시 세팅한 {@code checkClientRegister}
* 세션 마커로 통제한다. 마커는 홈 최초 진입에서 1회 소비하여, 이후 홈 재방문 시
* 반복 노출되지 않게 한다. (역할 게이팅은 마커 세팅 측에서 이미 수행됨)</p>
*/
private void resolveClientRegisterNudge(Model model, HttpSession session) {
if (session.getAttribute("checkClientRegister") == null) {
return;
}
session.removeAttribute("checkClientRegister"); // 1회 소비
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
if (user == null || user.getPortalOrg() == null) {
return;
}
boolean hasClient = !appServiceFacade.getApikeyList(user.getPortalOrg()).isEmpty()
|| !appServiceFacade.getPendingApiKeyList(user.getPortalOrg()).isEmpty();
if (!hasClient) {
model.addAttribute("needClientRegister", true);
}
}
private void addAttributesToModel(Model model, List<ApiServiceDTO> apiServices, List<String> hashTags, IndexStatisticsDTO statistics) {
model.addAttribute("services", apiServices);
@@ -172,6 +172,44 @@ public class StringMaskingUtil {
return value;
}
// 폼(application/x-www-form-urlencoded) 본문에서 값을 리댁트할 파라미터 키(소문자 완전일치)
private static final java.util.Set<String> SENSITIVE_FORM_PARAMS = new java.util.HashSet<>(Arrays.asList(
"client_secret", "clientsecret", "secret", "password", "passwd", "pwd",
"refresh_token", "access_token", "id_token", "code", "assertion"));
/**
* {@code application/x-www-form-urlencoded} 본문 로깅 시 민감 파라미터(client_secret/password 등)의
* 값을 {@link #maskToken(String)} 규칙(앞4·뒤4)으로 마스킹한다. client_id·grant_type·scope 등은
* "client not found" 류 원인 파악을 위해 원본 그대로 남긴다. 폼 형식이 아니면 원본을 반환한다(멱등).
* <pre>grant_type=client_credentials&client_id=ABC&client_secret=s3cr3tValue → …&client_secret=s3cr***alue</pre>
*/
public static String maskFormBody(String body) {
if (!isValidString(body) || body.indexOf('=') < 0) {
return body;
}
String[] pairs = body.split("&");
StringBuilder sb = new StringBuilder(body.length());
for (int i = 0; i < pairs.length; i++) {
if (i > 0) {
sb.append('&');
}
String pair = pairs[i];
int eq = pair.indexOf('=');
if (eq < 0) {
sb.append(pair);
continue;
}
String key = pair.substring(0, eq);
String value = pair.substring(eq + 1);
if (SENSITIVE_FORM_PARAMS.contains(key.toLowerCase())) {
sb.append(key).append('=').append(maskToken(value));
} else {
sb.append(pair);
}
}
return sb.toString();
}
// 기존 메서드 오버로딩 (하위 호환성)
public static String maskName(String name) {
return maskName(name, null, null);
@@ -60,7 +60,9 @@ public class PortalAuthenticationSuccessHandler implements AuthenticationSuccess
boolean dormant = PortalUserEnums.UserStatus.DORMANT.equals(user.getUserStatus());
// 로그인 2FA: ID/PW 는 맞았으므로 실패카운트만 리셋하고, 최종 확정은 2FA 성공까지 보류한다.
if (twoFactorProperties.isLoginEnabled() && !dormant) {
// 대상 역할(two-factor.login.target-roles, 기본 법인관리자만)에 한해 적용한다.
if (twoFactorProperties.isLoginEnabled() && !dormant
&& twoFactorProperties.isLoginTargetRole(user.getRoleCode())) {
user.setLoginFailureCount(0);
portalUserRepository.save(user);
+16 -16
View File
@@ -366,38 +366,38 @@ page:
name: "개발자 정보"
path: "/users/detail"
apikey:
name: " 관리"
path: "/myapikey"
name: "API 신청 관리"
path: "/clients"
app_request_detail:
name: "인증키 신청 상세"
path: "/myapikey/app_request_detail"
path: "/clients/app_request_detail"
credential_detail:
name: "인증키 정보"
path: "/myapikey/credential_detail"
path: "/clients/credential_detail"
password_verify:
name: "비밀번호 변경"
path: "/password/verify"
password_change:
name: "비밀번호 변경"
path: "/password/change"
myapikey_register_step1:
clients_register_step1:
name: "앱 생성 (기본 정보)"
path: "/myapikey/register/step1"
myapikey_register_step2:
path: "/clients/register/step1"
clients_register_step2:
name: "앱 생성 (API 선택)"
path: "/myapikey/register/step2"
myapikey_register_step3:
path: "/clients/register/step2"
clients_register_step3:
name: "앱 생성 요청 완료"
path: "/myapikey/register/step3"
myapikey_modify_step1:
path: "/clients/register/step3"
clients_modify_step1:
name: "앱 수정 (기본 정보)"
path: "/myapikey/modify/step1"
myapikey_modify_step2:
path: "/clients/modify/step1"
clients_modify_step2:
name: "앱 수정 (API 선택)"
path: "/myapikey/modify/step2"
myapikey_modify_step3:
path: "/clients/modify/step2"
clients_modify_step3:
name: "앱 수정 요청 완료"
path: "/myapikey/modify/step3"
path: "/clients/modify/step3"
api_statistics:
name: "이용 통계"
path: "/statistics/api"
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -449,7 +449,7 @@ document.addEventListener('DOMContentLoaded', function() {
// Form submit
// - Webhook/앱 변경은 API 가 필수(form data-api-required=true)이므로 미선택 시 차단한다.
// - 앱 신청(myapikey register)은 선택 사항이라, 미선택 시 확인 팝업으로 한 번 더 묻고 진행한다.
// - 앱 신청(clients register)은 선택 사항이라, 미선택 시 확인 팝업으로 한 번 더 묻고 진행한다.
form.addEventListener('submit', function(e) {
if (selectedApis.size !== 0) {
syncHiddenSelected();
+81
View File
@@ -0,0 +1,81 @@
/*!
* DjbToast — 경량 전역 토스트 알림.
* 사용: window.djbToast('메시지') 또는 djbToast('메시지', { type: 'info', duration: 3000 })
*
* 추가로, 페이지 진입 시 URL 쿼리 `apiApplyToast` 값에 따라 안내 토스트를 자동 노출한다.
* - new : 신규 클라이언트 생성 페이지 진입 안내
* - existing : 클라이언트 목록(선택) 진입 안내
* - modify : 클라이언트 1건 보유 → 수정 플로우 API 선택 직행 안내
* 토스트 노출 후 쿼리를 정리(replaceState)해 새로고침 시 재노출을 막는다.
*/
(function (global) {
"use strict";
var CONTAINER_ID = "djb-toast-container";
function ensureContainer() {
var el = document.getElementById(CONTAINER_ID);
if (!el) {
el = document.createElement("div");
el.id = CONTAINER_ID;
el.className = "djb-toast-container";
document.body.appendChild(el);
}
return el;
}
function showToast(message, opts) {
if (!message) return;
opts = opts || {};
var duration = typeof opts.duration === "number" ? opts.duration : 3200;
var type = opts.type || "info";
var container = ensureContainer();
var toast = document.createElement("div");
toast.className = "djb-toast djb-toast--" + type;
toast.setAttribute("role", "status");
toast.innerHTML = String(message); // 호출부에서만 신뢰 문자열 전달
container.appendChild(toast);
// enter 애니메이션
requestAnimationFrame(function () { toast.classList.add("is-show"); });
var remove = function () {
toast.classList.remove("is-show");
setTimeout(function () {
if (toast.parentNode) toast.parentNode.removeChild(toast);
}, 250);
};
var timer = setTimeout(remove, duration);
toast.addEventListener("click", function () { clearTimeout(timer); remove(); });
}
global.djbToast = showToast;
// ── URL 쿼리 기반 자동 안내 토스트 ──
var API_APPLY_MESSAGES = {
"new": "API 를 사용할 클라이언트 생성을 먼저 진행해주세요.",
"existing": "API 를 추가 사용할 클라이언트를 선택해주세요",
"modify": "보유 클라이언트에 추가 사용할 API 를 선택해주세요"
};
function handleApiApplyToast() {
var params = new URLSearchParams(global.location.search);
var key = params.get("apiApplyToast");
if (!key || !API_APPLY_MESSAGES[key]) return;
showToast(API_APPLY_MESSAGES[key], { type: "info" });
// 쿼리 제거 후 URL 정리 (새로고침 재노출 방지)
params.delete("apiApplyToast");
var qs = params.toString();
var newUrl = global.location.pathname + (qs ? "?" + qs : "") + global.location.hash;
try { global.history.replaceState(null, "", newUrl); } catch (e) { /* noop */ }
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", handleApiApplyToast);
} else {
handleApiApplyToast();
}
})(window);
@@ -31,6 +31,7 @@ var LoginSuccessHandler = (function() {
pendingInvitation: false,
pendingInvitationToken: '',
pendingInvitationOrgName: '',
needClientRegister: false,
sessionSuccessMsg: '',
redirectUrl: ''
};
@@ -117,6 +118,19 @@ var LoginSuccessHandler = (function() {
}
}
);
return; // 다른 체크 중단
}
// 6. 클라이언트(앱/API키) 미보유 법인 사용자 → 신규 신청 유도 (로그인 직후 1회)
if (config.needClientRegister) {
customPopups.showConfirm(
'API 사용을 위해서는 클라이언트 신규 신청이 필요합니다.<br>지금 신청하시겠습니까?',
function(selection) {
if (selection) {
window.location.href = '/clients/register/step1';
}
}
);
}
}
@@ -133,6 +133,7 @@ const customPopups = {
// 기본값 설정
const title = options.title || '비밀번호 입력';
const message = options.message || '계속하려면 비밀번호를 입력해주세요.';
const placeholder = options.placeholder || '비밀번호를 입력하세요';
const onConfirm = options.onConfirm;
const onCancel = options.onCancel;
@@ -140,8 +141,8 @@ const customPopups = {
$('#passwordPopupTitle').text(title);
$('#passwordPopupMessage').html(customPopups._sanitizeHtml(message));
// 입력 필드 및 에러 초기화
$('#passwordPopupInput').val('').removeClass('error');
// 입력 필드 및 에러 초기화 (placeholder 는 호출부 커스텀 허용)
$('#passwordPopupInput').val('').removeClass('error').attr('placeholder', placeholder);
$('#passwordPopupError').removeClass('show').text('');
// 팝업 표시 (modal 구조 사용)
@@ -3,7 +3,7 @@
*
* TwoFactorAuth.open({
* mode: 'login' | 'stepup', // 참고용(서버가 세션으로 판별). 로깅/분기용
* purpose: '/myapikey/...', // step-up 대상 보호 경로(로그인은 생략)
* purpose: '/clients/...', // step-up 대상 보호 경로(로그인은 생략)
* onSuccess: function(res){}, // 검증 성공. login 이면 res.redirect 사용
* onCancel: function(reason){}// 닫기/타임아웃/실패로 종료
* });
@@ -53,7 +53,7 @@
'op.tryItOut': { en: 'Try it out', ko: '실행해보기' },
'op.cancel': { en: 'Cancel', ko: '취소' },
'op.execute': { en: 'Execute', ko: '실행' },
'op.clear': { en: 'Clear', ko: '지우기' },
'op.clear': { en: 'Clear', ko: 'Reset (응답지우기)' },
'op.reset': { en: 'Reset', ko: '초기화' },
'op.parameters': { en: 'Parameters', ko: '파라미터' },
'op.parameter': { en: 'Parameter', ko: '파라미터' },
@@ -225,6 +225,18 @@
+ '</div>';
}
// Execute 후 응답(또는 검증 실패) 영역으로 자동 스크롤. 성공/검증실패 모두
// .djb-response-grid 로 수렴하므로 이 그리드를 뷰포트 상단으로 올린다.
function scrollToGrid(op) {
setTimeout(function () {
var grid = (op || document).querySelector(".djb-response-grid")
|| document.querySelector("#swagger-ui .djb-response-grid");
if (!grid) return;
try { grid.scrollIntoView({ behavior: "smooth", block: "start" }); }
catch (e) { grid.scrollIntoView(); }
}, 60);
}
// Execute 클릭 시점에 대상 opblock 확정 + (검증) + 타이머 시작 + 빈 그리드 표시.
// 캡처 단계라 Swagger(React) 의 실행 핸들러보다 먼저 실행됨 → 검증 실패 시
// stopImmediatePropagation 으로 native 실행을 차단하고 에러만 렌더한다.
@@ -247,6 +259,7 @@
e.preventDefault();
e.stopImmediatePropagation();
renderValidationErrors(result.errors);
scrollToGrid(targetOpblock);
return;
}
}
@@ -261,6 +274,7 @@
grid.classList.remove("djb-error");
grid.innerHTML = renderEmpty();
}
scrollToGrid(targetOpblock);
}
/**
@@ -225,6 +225,15 @@
withObserverPaused(function () { renderInto(panelEl()); });
return;
}
// Execute(실행) 클릭 시: Swagger 가 요청 DOM/값을 갱신한 뒤 현재 탭을 재렌더.
// (검증 실패는 response.js 캡처가 stopImmediatePropagation 으로 차단 → 여기 도달 안 함)
// Swagger 의 opblock-body 재렌더 타이밍이 유동적이라 짧게 두 번 갱신한다.
var exec = e.target.closest && e.target.closest(".btn.execute");
if (exec) {
setTimeout(function () { withObserverPaused(function () { renderInto(panelEl()); }); }, 80);
setTimeout(function () { withObserverPaused(function () { renderInto(panelEl()); }); }, 300);
return;
}
var copy = e.target.closest && e.target.closest('#djb-snippet-panel [data-action="copy"]');
if (copy) {
var code = document.querySelector("#djb-snippet-panel .djb-sn-code");
@@ -41,6 +41,40 @@
.swagger-ui .responses-inner > div { display: none !important; }
.swagger-ui .live-responses-table { display: none !important; }
/* 서버 영역(.scheme-container)
"서버" 셀렉트박스(라벨+select) 숨긴다 요청 URL spec servers[0] 값을 그대로
사용하므로 UI 제거해도 호출에는 영향 없음. 대신 영역 안으로 "앱 선택" 패널
(#appsWrap) JS 이관(relocate) 배치한다. */
.swagger-ui .scheme-container .servers-title,
.swagger-ui .scheme-container .servers,
.swagger-ui .scheme-container .schemes-server-container { display: none !important; }
/* 앱 선택(#appsWrap)과 인증(.auth-wrapper) 을 한 행에 8:2 로 배치 */
.swagger-ui .scheme-container .schemes {
flex-wrap: nowrap;
align-items: stretch;
gap: 16px;
}
.swagger-ui .scheme-container #appsWrap {
order: 1;
flex: 8 1 0;
min-width: 0; /* select 축약(truncate) 위해 shrink 허용 */
margin: 0;
}
.swagger-ui .scheme-container .auth-wrapper {
order: 2;
flex: 2 1 0;
min-width: 0;
align-items: center;
justify-content: stretch;
}
.swagger-ui .scheme-container .auth-wrapper .authorize {
width: 100%;
justify-content: center;
margin: 0;
padding-right: 0;
}
/* ─── 상시 요청 스니펫 패널 (PoC overlay.css #djb-snippet-panel) ─────────────── */
.swagger-ui #djb-snippet-panel {
margin: 16px;
@@ -0,0 +1,68 @@
// DjbToast (전역 토스트 알림)
// js/djb/toast.js 연동. 화면 우하단 스택 노출.
.djb-toast-container {
position: fixed;
top: 24px;
left: 50%;
transform: translateX(-50%);
z-index: 12000;
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
pointer-events: none;
@media (max-width: 768px) {
top: 12px;
left: 12px;
right: 12px;
transform: none;
align-items: stretch;
}
}
.djb-toast {
pointer-events: auto;
min-width: 260px;
max-width: 380px;
padding: 14px 18px;
border-radius: 10px;
background: #f4f7fc;
color: #1f2328;
font-size: 14px;
line-height: 1.5;
border: 1px solid #e3e8f0;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
cursor: pointer;
opacity: 0;
transform: translateY(-12px);
transition: opacity 0.22s ease, transform 0.22s ease;
&.is-show {
opacity: 1;
transform: translateY(0);
}
// 좌측 강조
border-left: 4px solid #0049B4;
&--info { border-left-color: #0049B4; }
&--success { border-left-color: #1f883d; }
&--warn { border-left-color: #bf8700; }
&--error { border-left-color: #cf222e; }
@media (max-width: 768px) {
max-width: none;
}
// 데스크탑: 크기 30% 확대 (가독성)
@media (min-width: 769px) {
min-width: 338px;
max-width: 494px;
padding: 18px 24px;
font-size: 18px;
border-radius: 13px;
}
}
@@ -203,9 +203,8 @@ $tfa-danger: #F4253C;
.tfa-code-input-wrap {
flex: 1;
display: flex;
align-items: center;
align-items: stretch;
height: 56px;
padding: 0 14px;
border: 1px solid $tfa-border-2;
border-radius: 4px;
background: #fff;
@@ -217,6 +216,8 @@ $tfa-danger: #F4253C;
.tfa-code-input {
flex: 1;
min-width: 0;
height: 100%;
padding: 0 14px;
border: 0;
outline: none;
background: transparent;
+1
View File
@@ -41,6 +41,7 @@
@use 'components/accordion' as *;
@use 'components/page-title-banner' as *;
@use 'components/alerts' as *;
@use 'components/toast' as *;
@use 'components/pagination' as *;
@use 'components/breadcrumb' as *;
@use 'components/test-env-notice' as *;
@@ -726,9 +726,9 @@
margin: $spacing-sm 0 0;
padding: $spacing-lg;
background: $white;
border: 1px solid $border-gray;
border: none;
border-radius: $border-radius-lg;
box-shadow: $shadow-sm;
box-shadow: none;
.testbed-app-panel__head {
display: flex;
@@ -846,6 +846,22 @@
}
// API Overview Card (Flat Style)
/* "기본 정보" 헤더: 좌측 제목 + 우측 "API 사용 신청" 버튼 */
.api-basic-info-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: $spacing-md;
flex-wrap: wrap;
}
/* 하단 중앙 "API 사용 신청" 버튼 */
.api-apply-footer {
display: flex;
justify-content: center;
margin-top: $spacing-xl;
}
.api-overview-card {
background: transparent;
border-radius: 0;
@@ -25,6 +25,12 @@
color: #4a5568;
}
.statistics-notice-example {
flex-basis: 100%; /* flex-wrap 컨테이너에서 두 번째 줄로 강제 */
color: #6b7280;
font-size: 12px;
}
.statistics-notice-latest {
color: #0049B4;
font-weight: 600;
@@ -3249,6 +3249,34 @@ input[type="checkbox"]:checked+.custom-checkbox {
}
}
// 선택 옵션 필드용 compact 변형: 높이 축소 + 가로 배치
.s1-upload-box--compact {
min-height: 96px;
.s1-upload-inner {
flex-direction: row;
flex-wrap: wrap;
justify-content: center;
gap: 6px 14px;
padding: 12px 16px;
}
.s1-upload-inner > img {
width: 28px;
height: 28px;
}
.s1-upload-title {
font-size: 13px;
}
.s1-btn-upload {
height: 34px;
padding: 0 16px;
font-size: 13px;
}
}
.s1-upload-inner {
display: flex;
flex-direction: column;
@@ -554,6 +554,48 @@ $service-card-shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgb
justify-content: center;
margin-top: 20px;
}
// 권한별 이용 가능 서비스 (TBD placeholder)
.signup-roles-tbd {
margin-top: 40px;
&__title {
font-size: 20px;
font-weight: 700;
color: #140064;
margin: 0 0 16px;
}
&__placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 48px 24px;
background: #f8f9fa;
border: 1px dashed #e3e8f0;
border-radius: 12px;
text-align: center;
}
&__badge {
display: inline-block;
padding: 4px 12px;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.04em;
color: #0049B4;
background: #eff9fe;
border-radius: 20px;
}
&__desc {
margin: 0;
font-size: 15px;
color: #6e7781;
}
}
}
// Responsive
@@ -48,7 +48,7 @@
<!-- Header -->
<div class="api-market-header">
<div class="api-market-title">
<h1>서비스</h1>
<h1 th:text="${apiSpecInfo.apiGroupName}">그룹명</h1>
<h2 th:text="${apiSpecInfo.apiName}">API 이름</h2>
</div>
</div>
@@ -69,8 +69,9 @@
<div class="tab-content" th:classappend="${activeTab == 'testbed'} ? '' : 'active'" id="api-info-tab">
<!-- API Overview Card (Merged with Additional Information) -->
<div class="api-overview-card">
<div class="org-section-header org-section-header--agreement">
<div class="org-section-header org-section-header--agreement api-basic-info-header">
<h3>기본 정보</h3>
<button type="button" class="btn-action-primary md api-apply-btn">API 사용 신청</button>
</div>
<div class="api-overview-header">
<div class="api-method-badge" th:text="${apiSpecInfo.apiMethod}">GET</div>
@@ -142,6 +143,11 @@
</div>
</div>
</div>
<!-- 하단 중앙 API 사용 신청 -->
<div class="api-apply-footer">
<button type="button" class="btn-action-primary api-apply-btn">API 사용 신청</button>
</div>
</div>
<!-- Testbed Tab Content -->
@@ -275,6 +281,8 @@
// 활성 탭은 서버가 결정하고, 테스트베드 탭이 활성이면서 인증된 경우에만
// Swagger UI를 초기화한다(실제 초기화는 하단에서 실행 — const 선언 이후).
const currentApiId = /*[[${apiSpecInfo.apiId}]]*/ 'default';
// 현재 API 의 응답유형(sample/mock/gw) — mock 이면 샘플 Secret + mock 토큰으로 인증 진행
const CURRENT_RESPONSE_TYPE = /*[[${apiSpecInfo.responseType}]]*/ 'sample';
const activeTab = /*[[${activeTab}]]*/ 'api-info';
const isAuthenticated = /*[[${authenticated}]]*/ false;
const testbedActive = (activeTab === 'testbed' && isAuthenticated);
@@ -364,6 +372,8 @@
onComplete: function() {
// 단일 API 페이지 — 상시 요청 스니펫 패널 마운트
setTimeout(function() { try { window.DjbSwaggerSnippetPanel.mount({ rootSel: '#swagger-ui' }); } catch (e) { console.error(e); } }, 50);
// "앱 선택" 패널을 Swagger 서버 영역(.scheme-container) 안으로 이관
setTimeout(function() { try { relocateAppPanel(); } catch (e) { console.error(e); } }, 50);
},
showMutatedRequest: false,
validatorUrl: '',
@@ -411,10 +421,88 @@
});
}
// ===== API 사용 신청: 회원 구분별 분기 (API 신청 절차) =====
// 비회원/개인회원 → 법인회원 안내 팝업 → 회원가입 안내 이동
// 법인이용자 → 클라이언트 현황: 신규(0건)→신규 클라이언트 / 기존(1건)→클라이언트 수정의 API 선택 직행 / 기존(1건 초과)→클라이언트 관리
function requestApiUse() {
fetch(/*[[@{/djb/testbed/auth/context}]]*/ '/djb/testbed/auth/context')
.then(function (r) { return r.json(); })
.then(function (ctx) {
const reason = ctx && ctx.reason;
if (reason === 'ANONYMOUS' || reason === 'INDIVIDUAL_USER') {
const go = function () { window.location.href = /*[[@{/service/guide}]]*/ '/service/guide'; };
// custom-popups.js 는 `const customPopups`(전역 렉시컬) 로 노출 — window 프로퍼티 아님.
if (typeof customPopups !== 'undefined' && customPopups.showAlert) {
customPopups.showAlert(
'API 사용 신청은 법인회원(법인관리자·법인개발자)만 이용할 수 있습니다.<br>회원 가입 안내 페이지로 이동합니다.',
go
);
} else { go(); }
return;
}
// 법인이용자: 보유 클라이언트 수로 신규/기존 분기 (이동 페이지에서 Toast 안내)
const creds = (ctx && ctx.credentials) || [];
if (creds.length === 1) {
// 기존 1건 → 해당 클라이언트 수정 플로우의 API 선택(2단계) 직행 + Toast
const modifyBase = /*[[@{/clients/modify/step1}]]*/ '/clients/modify/step1';
window.location.href = modifyBase
+ '?clientId=' + encodeURIComponent(creds[0].clientId)
+ '&goto=apis&apiApplyToast=modify';
return;
}
if (creds.length > 1) {
// 기존 여러 건 → 클라이언트 목록(선택) + Toast
window.location.href = /*[[@{/clients(apiApplyToast='existing')}]]*/ '/clients?apiApplyToast=existing';
return;
}
// 신규(클라이언트 0) → 생성 필요 안내 확인 후 이동
const goNew = function () {
window.location.href = /*[[@{/clients/register/step1(clear=true,apiApplyToast='new')}]]*/ '/clients/register/step1?clear=true&apiApplyToast=new';
};
if (typeof customPopups !== 'undefined' && customPopups.showConfirm) {
customPopups.showConfirm(
'API 를 이용하려면 신규 클라이언트 생성이 필요합니다.<br>클라이언트 생성 페이지로 이동하시겠습니까?',
function (ok) { if (ok) goNew(); }
);
} else { goNew(); }
})
.catch(function (e) {
console.error('API 사용 신청 컨텍스트 조회 실패', e);
window.location.href = /*[[@{/clients}]]*/ '/clients';
});
}
Array.prototype.forEach.call(document.querySelectorAll('.api-apply-btn'), function (btn) {
btn.addEventListener('click', requestApiUse);
});
// ===== DJPGPT0001: 앱 인증 정보 자동 주입 =====
const DEFAULT_TOKEN_API_ID = 'default-token-api-spec';
const appsSelect = document.getElementById('apps');
// "앱 선택" 패널(#appsWrap)을 Swagger 서버 영역(.scheme-container) 안으로 이관.
// 서버 셀렉트박스는 CSS 로 숨기고, 그 자리에 앱 선택을 배치한다.
// SwaggerUI 가 scheme-container 를 재렌더하면 노드가 이탈할 수 있어 observer 로 재고정.
function relocateAppPanel() {
const panel = document.getElementById('appsWrap');
if (!panel) return;
const scheme = document.querySelector('#swagger-ui .scheme-container');
if (!scheme) return;
const host = scheme.querySelector('.schemes') || scheme;
if (panel.parentNode !== host) host.appendChild(panel);
}
// scheme-container 재렌더 대비: #swagger-ui 변경 감지 시 재이관(멱등)
(function watchAppPanelRelocation() {
const root = document.getElementById('swagger-ui');
if (!root) return;
const obs = new MutationObserver(function () {
const panel = document.getElementById('appsWrap');
const host = document.querySelector('#swagger-ui .scheme-container .schemes')
|| document.querySelector('#swagger-ui .scheme-container');
if (panel && host && panel.parentNode !== host) host.appendChild(panel);
});
obs.observe(root, { childList: true, subtree: true });
})();
function eligibilityMessage(reason) {
switch (reason) {
case 'ANONYMOUS': return '로그인 후 본인 앱의 인증 정보를 사용할 수 있습니다.';
@@ -438,43 +526,136 @@
}
appsSelect.disabled = false;
if (notice) notice.style.display = 'none';
// 표시용 client id 축약: 앞 5글자 + "..." (value 는 full 유지 → 인증에 사용)
const abbrevId = function (id) {
id = id || '';
return id.length > 5 ? (id.slice(0, 5) + '...') : id;
};
(ctx.credentials || []).forEach(function (c) {
const opt = document.createElement('option');
opt.value = c.clientId;
opt.textContent = c.clientName ? (c.clientName + ' (' + c.clientId + ')') : c.clientId;
opt.textContent = c.clientName
? (c.clientName + ' (' + abbrevId(c.clientId) + ')')
: abbrevId(c.clientId);
appsSelect.appendChild(opt);
});
})
.catch(function (e) { console.error('테스트베드 컨텍스트 로드 실패', e); });
}
function fetchOAuthToken(clientId, clientSecret, gw) {
// 인증 실패/취소 시 앱 선택을 미선택 상태로 되돌린다 (mock 포함 — 선택 유지 시 인증된 것처럼 보이는 오해 방지)
function resetAppSelection() {
if (appsSelect) appsSelect.value = '';
}
// 테스트베드 오류 안내 — modal dialog (custom-popups)
function showTestbedError(msg) {
if (typeof customPopups !== 'undefined' && customPopups.showAlert) {
customPopups.showAlert(msg);
} else {
alert(String(msg).replace(/<br\s*\/?>/gi, '\n'));
}
}
function fetchOAuthToken(clientId, clientSecret, tokenUrl) {
const body = 'grant_type=client_credentials'
+ '&client_id=' + encodeURIComponent(clientId)
+ '&client_secret=' + encodeURIComponent(clientSecret)
+ '&scope=api';
// 토큰 발급 프록시 여부(djb.gateway.token-use-proxy). false 면 토큰 URL 로 브라우저 직접 호출.
var direct = (window.__djbTokenUseProxy === false);
var url = direct ? gw.tokenUrl : window.location.origin + (/*[[@{/api/call-api}]]*/ '/api/call-api');
var url = direct ? tokenUrl : window.location.origin + (/*[[@{/api/call-api}]]*/ '/api/call-api');
var headers = { 'Content-Type': 'application/x-www-form-urlencoded' };
if (!direct) {
headers['original-url'] = gw.tokenUrl;
headers['original-url'] = tokenUrl;
headers['X-XSRF-TOKEN'] = /*[[${_csrf.token}]]*/ '';
}
return fetch(url, {
method: 'POST',
headers: headers,
body: body
}).then(function (r) { return r.ok ? r.json() : null; })
.then(function (data) { return data ? data.access_token : null; });
}).then(function (r) {
// 실패 시에도 프록시(ApiTesterFilter)가 {error, detail} JSON 을 반환 — 메시지로 살려 UI 에 표시
return r.json().catch(function () { return null; }).then(function (data) {
if (!r.ok || !data || !data.access_token) {
var msg = (data && (data.error || data.error_description))
|| ('토큰 발급 요청 실패 (HTTP ' + r.status + ')');
var detail = (data && data.detail) ? '<br><span style="color:#888;font-size:13px;">' + data.detail + '</span>' : '';
throw new Error('인증 토큰 발급에 실패했습니다.<br>' + msg + detail);
}
return data.access_token;
});
});
}
// 현재 API 가 mock 응답유형인지 (전역 게이트웨이 설정과 무관하게 API 별 판단)
function isMockApi() {
return (CURRENT_RESPONSE_TYPE || '').toLowerCase() === 'mock';
}
function authorizeApp(secret) {
const gw = window.__djbGateway || {};
if (!window.ui) return;
// 응답유형 mock: 실 Secret 검증 없이 샘플 SecretKey + mock 토큰으로 인증 진행 — toast 안내
if (isMockApi()) {
if (typeof djbToast === 'function') {
djbToast('Mock 서버로 호출되므로 별도 Secret Key 로 인증 절차를 진행합니다.', { type: 'info', duration: 5000 });
}
proceedAuthorize(secret, secret.clientSecret || 'mock-secret', gw);
return;
}
// gw 응답유형 + Client Secret 이미 1회 노출·삭제 → 사용자가 보관 중인 Secret 직접 입력
if (!secret.clientSecret) {
promptClientSecret(secret, gw);
return;
}
proceedAuthorize(secret, secret.clientSecret, gw);
}
// Client Secret 수동 입력 팝업 (포탈에는 이미 삭제된 경우)
function promptClientSecret(secret, gw) {
if (typeof customPopups === 'undefined' || !customPopups.showPasswordInput) {
resetAppSelection();
showTestbedError('선택한 앱의 Client Secret 이 포탈에 존재하지 않습니다.<br>인증키를 새로 신청해 주세요.');
return;
}
customPopups.showPasswordInput({
title: 'Client Secret 입력',
message: 'Client Secret 은 1회 노출 후 삭제되어 포탈에 없습니다.<br>'
+ '보관 중인 값을 입력해 주세요.<br><br>'
+ '입력한 값은 저장되지 않고 인증에만 사용됩니다.',
placeholder: 'Client Secret 을 입력하세요',
onConfirm: function (value) {
customPopups.hidePasswordInput();
proceedAuthorize(secret, value, gw);
},
onCancel: function () {
// 취소 — 인증 미완료이므로 앱 선택도 되돌린다
resetAppSelection();
}
});
}
// authorize 반영 직후, 이미 열려 있는 "요청 스니펫" 상시 패널을 재렌더해 인증 헤더를 즉시 반영.
// (패널은 탭 클릭/Execute/입력 변경에만 자체 갱신하므로 앱 선택 시점엔 명시 호출이 필요)
function refreshSnippetPanel() {
if (window.DjbSwaggerSnippetPanel && typeof DjbSwaggerSnippetPanel.render === 'function') {
// authorize 상태가 Swagger store 에 반영된 뒤 읽도록 한 틱 늦춘다
setTimeout(function () { DjbSwaggerSnippetPanel.render(); }, 50);
}
}
// 확보된 Client Secret 으로 인증 주입 (OAUTH: 토큰 발급 / API_KEY: 헤더 주입)
// 토큰 URL: mock API → 포탈 mock 토큰 경로(ApiTesterFilter 가 즉시 발급), gw API → 실 GW 토큰 엔드포인트
function proceedAuthorize(secret, clientSecret, gw) {
var tokenUrl = isMockApi()
? window.location.origin + '/api/v1/oauth/token'
: gw.tokenUrl;
if (secret.authType === 'OAUTH') {
fetchOAuthToken(secret.clientId, secret.clientSecret, gw).then(function (token) {
if (!token) return;
fetchOAuthToken(secret.clientId, clientSecret, tokenUrl).then(function (token) {
window.ui.authActions.authorize({
djbOAuth: {
name: 'djbOAuth',
@@ -482,15 +663,21 @@
value: 'Bearer ' + token
}
});
refreshSnippetPanel();
}).catch(function (e) {
console.error('토큰 발급 실패', e);
resetAppSelection();
showTestbedError(e && e.message ? e.message : '인증 토큰 발급 중 오류가 발생했습니다.');
});
} else if (secret.authType === 'API_KEY') {
window.ui.authActions.authorize({
djbApiKey: {
name: 'djbApiKey',
schema: { type: 'apiKey', in: 'header', name: gw.apiKeyHeader },
value: secret.clientSecret
value: clientSecret
}
});
refreshSnippetPanel();
}
}
@@ -499,9 +686,18 @@
if (!currentApiId || currentApiId === 'default' || currentApiId === DEFAULT_TOKEN_API_ID) return;
fetch((/*[[@{/djb/testbed/auth/credentials/}]]*/ '/djb/testbed/auth/credentials/')
+ encodeURIComponent(clientId) + '/secret?apiId=' + encodeURIComponent(currentApiId))
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (r) {
if (!r.ok) {
throw new Error('앱 인증정보 조회에 실패했습니다. (HTTP ' + r.status + ')');
}
return r.json();
})
.then(function (secret) { if (secret) authorizeApp(secret); })
.catch(function (e) { console.error('앱 인증정보 주입 실패', e); });
.catch(function (e) {
console.error('앱 인증정보 주입 실패', e);
resetAppSelection();
showTestbedError(e && e.message ? e.message : '앱 인증정보 주입 중 오류가 발생했습니다.');
});
}
// 테스트베드 탭 활성 + 인증 상태일 때만 Swagger UI/앱 인증 컨텍스트 초기화
@@ -17,7 +17,7 @@
<p class="hero-subtitle">세상의 모든 서비스</p>
<h2 class="hero-title">DJBank API가<br>함께 합니다.</h2>
</div>
<a href="#" class="btn-hero-signup">회원가입하기 <i class="bi bi-chevron-right"></i></a>
<a href="#" class="btn-hero-signup">회원 가입 안내 <i class="bi bi-chevron-right"></i></a>
</div>
<div class="hero-image-content">
<!-- Inline SVG Tech Illustration -->
@@ -327,8 +327,8 @@
DJBank API Portal은 기업이 혁신적인 금융 서비스를 쉽고 신속하게 개발하고 구현할 수 있도록 엄선된 API 명세와 직관적인 샌드박스 테스트 환경을 무상으로 지원합니다.
</p>
<div class="action-buttons">
<a href="#" class="action-btn btn-secondary">처음 만나는 DJBank API</a>
<a href="#" class="action-btn btn-primary">피드백 / 개선요청 <i class="bi bi-patch-question"></i></a>
<a th:href="@{/service/intro}" class="action-btn btn-secondary">처음 만나는 DJBank API</a>
<a th:href="@{/partnership}" class="action-btn btn-primary">피드백 / 개선요청 <i class="bi bi-patch-question"></i></a>
</div>
</div>
<div class="info-image-box">
@@ -607,6 +607,7 @@
pendingInvitation: [[${ session.pendingInvitation }]],
pendingInvitationToken: [[${ session.pendingInvitationToken }]],
pendingInvitationOrgName: [[${ session.pendingInvitationOrgName }]],
needClientRegister: [[${ needClientRegister }]],
sessionSuccessMsg: [[${ session.success }]],
redirectUrl: [[${ session.redirectUrl }]]
});
@@ -21,10 +21,10 @@
<div class="app-management-content">
<!-- Header -->
<div class="app-management-header">
<h2>인증 키 관리</h2>
<h2>클라이언트/API 신청 관리</h2>
<div sec:authorize="hasRole('ROLE_API_KEY_REQUEST')">
<button type="button" class="btn-action-primary md" id="requestApiKey">
생성
클라이언트 생성
</button>
</div>
</div>
@@ -35,7 +35,7 @@
<!-- App Requests (Pending) -->
<th:block th:if="${appRequests != null and !appRequests.isEmpty()}">
<a class="app-card-figma" th:each="request : ${appRequests}"
th:href="@{/myapikey/app_request_detail(id=${request.id})}">
th:href="@{/clients/app_request_detail(id=${request.id})}">
<!-- App Icon -->
<div class="app-card-icon-box">
@@ -85,7 +85,7 @@
<!-- API Keys (Approved/Inactive) -->
<th:block th:if="${apiKeys != null and !apiKeys.isEmpty()}">
<a class="app-card-figma" th:each="apikey : ${apiKeys}"
th:href="@{/myapikey/credential_detail(id=${apikey.clientid})}">
th:href="@{/clients/credential_detail(id=${apikey.clientid})}">
<!-- App Icon -->
<div class="app-card-icon-box">
@@ -156,7 +156,7 @@
keysToRemove.forEach(key => sessionStorage.removeItem(key));
// Redirect to the API key registration wizard with clear parameter
window.location.href = '/myapikey/register/step1?clear=true';
window.location.href = '/clients/register/step1?clear=true';
}
if (requestApiKeyBtn) {
@@ -22,12 +22,12 @@
<div class="step1-wrap">
<!-- Title -->
<h2 class="s1-title">앱 수정</h2>
<h2 class="s1-title">클라이언트 정보 - 변경</h2>
<!-- Progress Steps Card -->
<div class="s1-progress-card">
<div class="s1-steps">
<!-- Step 1 Active: 정보수정 (form/input icon) -->
<!-- Step 1 Active: 클라이언트 정보수정 (form/input icon) -->
<div class="s1-step s1-step--active">
<div class="s1-step-circle">
<svg class="s1-step-svg" width="26" height="26" viewBox="0 0 24 24" fill="none"
@@ -39,7 +39,7 @@
</svg>
</div>
<span class="s1-step-num">1단계</span>
<span class="s1-step-name"> 정보수정</span>
<span class="s1-step-name">클라이언트 정보수정</span>
</div>
<div class="s1-step-line"></div>
@@ -71,7 +71,7 @@
</svg>
</div>
<span class="s1-step-num">3단계</span>
<span class="s1-step-name">완료</span>
<span class="s1-step-name">변경 신청 완료</span>
</div>
</div>
@@ -84,48 +84,12 @@
<!-- Form Card -->
<div class="s1-form-card">
<form id="modifyStep1Form" method="post" th:action="@{/myapikey/modify/step1}"
<form id="modifyStep1Form" method="post" th:action="@{/clients/modify/step1}"
th:object="${apiKeyModification}" enctype="multipart/form-data">
<!-- Hidden clientId -->
<input type="hidden" th:field="*{clientId}"/>
<!-- 앱 아이콘 -->
<div class="s1-field">
<label class="s1-label">앱 아이콘</label>
<div class="s1-upload-box" id="iconDropZone">
<!-- 기존 파일 ID로 이미지 노출 -->
<img class="s1-preview-img" id="iconPreviewImage1"
th:if="${apiKeyModification.appIconFileId != null}"
th:src="@{/file/download(fileSn=1,fileId=${apiKeyModification.appIconFileId})}"
alt="미리보기" style="display: block;">
<!-- 데이터에서 이미지 노출 -->
<img class="s1-preview-img" id="iconPreviewImage2"
th:if="${apiKeyModification.appIconData != null and apiKeyModification.appIconFileId == null}"
th:src="'data:' + ${apiKeyModification.appIconContentType} + ';base64,' + ${T(java.util.Base64).getEncoder().encodeToString(apiKeyModification.appIconData)}"
alt="미리보기" style="display: block;">
<!-- JS용 이미지 타겟 -->
<img id="iconPreviewImageJS" class="s1-preview-img" style="display: none;" alt="미리보기">
<div id="iconPlaceholder" class="s1-upload-inner" th:style="${(apiKeyModification.appIconData != null or apiKeyModification.appIconFileId != null) ? 'display: none;' : 'display: flex;'}">
<img th:src="@{/img/icon/img_icon.png}">
<p class="s1-upload-title">앱 아이콘 이미지 파일을 업로드 하세요</p>
<p class="s1-upload-hint">권장 사이즈는 1280 * 12 픽셀이며 JPG,PNG,GIF 파일만 등록할 수 있습니다.</p>
<button type="button" class="s1-btn-upload"
onclick="document.getElementById('appIconFile').click()">
이미지 파일 업로드
</button>
</div>
<button type="button" class="s1-btn-remove-icon" id="btnRemoveIcon"
th:style="${(apiKeyModification.appIconData != null or apiKeyModification.appIconFileId != null) ? 'display: flex;' : 'display: none;'}"
onclick="removeAppIcon()">✕</button>
<input type="file" id="appIconFile" name="appIcon" accept="image/png,image/jpeg,image/jpg,image/gif" style="display:none;">
</div>
</div>
<!-- 앱 이름 -->
<div class="s1-field">
<label class="s1-label">앱 이름 <span class="s1-required">*</span></label>
@@ -168,12 +132,48 @@
<input type="hidden" id="ipWhitelist" name="ipWhitelist" value="">
</div>
<!-- 앱 아이콘 (선택 옵션 — 최하단 배치, compact) -->
<div class="s1-field">
<label class="s1-label">앱 아이콘</label>
<div class="s1-upload-box s1-upload-box--compact" id="iconDropZone">
<!-- 기존 파일 ID로 이미지 노출 -->
<img class="s1-preview-img" id="iconPreviewImage1"
th:if="${apiKeyModification.appIconFileId != null}"
th:src="@{/file/download(fileSn=1,fileId=${apiKeyModification.appIconFileId})}"
alt="미리보기" style="display: block;">
<!-- 데이터에서 이미지 노출 -->
<img class="s1-preview-img" id="iconPreviewImage2"
th:if="${apiKeyModification.appIconData != null and apiKeyModification.appIconFileId == null}"
th:src="'data:' + ${apiKeyModification.appIconContentType} + ';base64,' + ${T(java.util.Base64).getEncoder().encodeToString(apiKeyModification.appIconData)}"
alt="미리보기" style="display: block;">
<!-- JS용 이미지 타겟 -->
<img id="iconPreviewImageJS" class="s1-preview-img" style="display: none;" alt="미리보기">
<div id="iconPlaceholder" class="s1-upload-inner" th:style="${(apiKeyModification.appIconData != null or apiKeyModification.appIconFileId != null) ? 'display: none;' : 'display: flex;'}">
<img th:src="@{/img/icon/img_icon.png}">
<p class="s1-upload-title">앱 아이콘 이미지 파일을 업로드 하세요</p>
<p class="s1-upload-hint">권장 사이즈는 512 * 512 픽셀이며 JPG,PNG,GIF 파일만 등록할 수 있습니다.</p>
<button type="button" class="s1-btn-upload"
onclick="document.getElementById('appIconFile').click()">
이미지 파일 업로드
</button>
</div>
<button type="button" class="s1-btn-remove-icon" id="btnRemoveIcon"
th:style="${(apiKeyModification.appIconData != null or apiKeyModification.appIconFileId != null) ? 'display: flex;' : 'display: none;'}"
onclick="removeAppIcon()">✕</button>
<input type="file" id="appIconFile" name="appIcon" accept="image/png,image/jpeg,image/jpg,image/gif" style="display:none;">
</div>
</div>
</form>
</div>
<!-- 다음 버튼 -->
<div class="s1-actions" style="justify-content: flex-end; gap: 10px;">
<a th:href="@{/myapikey/modify/cancel(clientId=${apiKeyModification.clientId})}" class="s1-btn-next" style="background: #bdc7cf; flex: none; width: 120px; text-decoration: none; display: flex; justify-content: center; align-items: center;">취소</a>
<a th:href="@{/clients/modify/cancel(clientId=${apiKeyModification.clientId})}" class="s1-btn-next" style="background: #bdc7cf; flex: none; width: 120px; text-decoration: none; display: flex; justify-content: center; align-items: center;">취소</a>
<button type="submit" form="modifyStep1Form" class="s1-btn-next" style="flex: none; width: 120px;">다음</button>
</div>
@@ -204,7 +204,8 @@
removeBtn.style.display = 'none';
}
function addIpAddress() {
function addIpAddress(refocus) {
if (refocus === undefined) refocus = true;
const ipInput = document.getElementById('ipWhitelistInput');
const ipValue = ipInput.value.trim();
@@ -227,7 +228,7 @@
ipAddresses.push(ipValue);
renderIpList();
ipInput.value = '';
ipInput.focus();
if (refocus) ipInput.focus();
}
function removeIpAddress(ip) {
@@ -342,8 +343,18 @@
}
// IP input Enter key
ipInput.addEventListener('keypress', function (e) {
if (e.key === 'Enter') { e.preventDefault(); addIpAddress(); }
ipInput.addEventListener('blur', function (e) {
const rt = e.relatedTarget;
if (rt && rt.closest && rt.closest('.s1-ip-btn--add')) return;
if (!ipInput.value.trim()) return; // 빈 값은 무시
addIpAddress(false); // blur 경로: 재포커스 안 함
});
// Enter/Tab 모두 입력값 등록. Tab 은 다음 tab-stop(추가 버튼)으로 포커스만
// 이동(onclick 미발동)하므로 keydown 에서 직접 추가한다. blur 보다 먼저 실행되어
// 중복 없이 처리된다.
ipInput.addEventListener('keydown', function (e) {
if (e.key === 'Enter') { e.preventDefault(); addIpAddress(); return; }
if (e.key === 'Tab' && ipInput.value.trim()) { addIpAddress(false); }
});
// Form submit validation
@@ -23,12 +23,12 @@
<div class="step2-wrap">
<!-- Title -->
<h2 class="s2-title">앱 수정</h2>
<h2 class="s2-title">클라이언트 정보 - 변경</h2>
<!-- Progress Steps Card -->
<div class="s1-progress-card">
<div class="s1-steps">
<!-- Step 1: 정보수정 (completed) -->
<!-- Step 1: 클라이언트 정보수정 (completed) -->
<div class="s1-step">
<div class="s1-step-circle">
<!-- 입력폼 아이콘 -->
@@ -44,7 +44,7 @@
</svg>
</div>
<span class="s1-step-num">1단계</span>
<span class="s1-step-name"> 정보수정</span>
<span class="s1-step-name">클라이언트 정보수정</span>
</div>
<div class="s1-step-line"></div>
@@ -86,13 +86,13 @@
</svg>
</div>
<span class="s1-step-num">3단계</span>
<span class="s1-step-name">앱 수정 완료</span>
<span class="s1-step-name">변경 신청 완료</span>
</div>
</div>
</div>
<!-- API 선택 공용 모듈 -->
<th:block th:replace="~{fragment/api_selector :: apiSelector(${apiServices}, ${apiKeyModification.selectedApis}, '/myapikey/modify/step2', '/myapikey/modify/step2/save')}"/>
<th:block th:replace="~{fragment/api_selector :: apiSelector(${apiServices}, ${apiKeyModification.selectedApis}, '/clients/modify/step2', '/clients/modify/step2/save')}"/>
<!-- clientId 는 모듈 폼에 form 속성으로 주입 -->
<input type="hidden" name="clientId" th:value="${apiKeyModification.clientId}" form="apiSelectorForm"/>
@@ -135,7 +135,7 @@
if (typeof TwoFactorAuth === 'undefined') { form.submit(); return; }
TwoFactorAuth.open({
mode: 'stepup',
purpose: '/myapikey/modify/step2',
purpose: '/clients/modify/step2',
// form.submit() 은 submit 이벤트를 재발생시키지 않으므로 그대로 서버로 전송된다.
onSuccess: function () { form.submit(); },
onCancel: function () { /* 사용자 취소 — step2 유지 */ }
@@ -24,12 +24,12 @@
<div class="step3-wrap">
<!-- Title -->
<h2 class="s3-title">앱 수정</h2>
<h2 class="s3-title">클라이언트 정보 - 변경</h2>
<!-- Progress Steps Card -->
<div class="s1-progress-card">
<div class="s1-steps">
<!-- Step 1: 정보수정 (form/input icon) -->
<!-- Step 1: 클라이언트 정보수정 (form/input icon) -->
<div class="s1-step">
<div class="s1-step-circle">
<!-- 입력폼 아이콘 -->
@@ -45,7 +45,7 @@
</svg>
</div>
<span class="s1-step-num">1단계</span>
<span class="s1-step-name"> 정보수정</span>
<span class="s1-step-name">클라이언트 정보수정</span>
</div>
<div class="s1-step-line"></div>
@@ -87,7 +87,7 @@
</svg>
</div>
<span class="s1-step-num">3단계</span>
<span class="s1-step-name">앱 수정 완료</span>
<span class="s1-step-name">변경 신청 완료</span>
</div>
</div>
</div>
@@ -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>
@@ -161,7 +161,7 @@
<!-- Bottom Actions -->
<div class="s3-actions">
<a href="/myapikey" class="s3-btn-complete">
<a href="/clients" class="s3-btn-complete">
완료
</a>
</div>
@@ -179,10 +179,10 @@
</p>
</div>
<div class="s3-actions">
<a href="/myapikey" class="s3-btn-retry">
<a href="/clients" class="s3-btn-retry">
다시 시도
</a>
<a href="/myapikey" class="s3-btn-list">
<a href="/clients" class="s3-btn-list">
목록으로
</a>
</div>
@@ -24,7 +24,7 @@
<div class="step1-wrap">
<!-- Title -->
<h2 class="s1-title">앱생성</h2>
<h2 class="s1-title">클라이언트 정보 - 신규</h2>
<!-- Progress Steps Card -->
<div class="s1-progress-card">
@@ -84,7 +84,7 @@
</svg>
</div>
<span class="s1-step-num">3단계</span>
<span class="s1-step-name">앱 생성 완료</span>
<span class="s1-step-name">클라이언트 신청 완료</span>
</div>
</div>
@@ -97,30 +97,9 @@
<!-- Form Card -->
<div class="s1-form-card">
<form id="registerStep1Form" method="post" th:action="@{/myapikey/register/step1}"
<form id="registerStep1Form" method="post" th:action="@{/clients/register/step1}"
th:object="${apiKeyRegistration}" enctype="multipart/form-data">
<!-- 앱 아이콘 -->
<div class="s1-field">
<label class="s1-label">앱 아이콘</label>
<div class="s1-upload-box" id="iconDropZone">
<img id="iconPreviewImage" class="s1-preview-img" style="display:none;" alt="미리보기">
<div id="iconPlaceholder" class="s1-upload-inner">
<img th:src="@{/img/icon/img_icon.png}">
<p class="s1-upload-title">앱 아이콘 이미지 파일을 업로드 하세요</p>
<p class="s1-upload-hint">권장 사이즈는 1280 * 12 픽셀이며 JPG,PNG,GIF 파일만 등록할 수 있습니다.</p>
<button type="button" class="s1-btn-upload"
onclick="document.getElementById('appIconFile').click()">
이미지 파일 업로드
</button>
</div>
<button type="button" class="s1-btn-remove-icon" id="btnRemoveIcon" style="display:none;"
onclick="removeAppIcon()">✕</button>
<input type="file" id="appIconFile" name="appIcon" accept="image/png,image/jpeg,image/jpg,image/gif"
style="display:none;">
</div>
</div>
<!-- 앱 이름 -->
<div class="s1-field">
<label class="s1-label">앱 이름 <span class="s1-required">*</span></label>
@@ -171,6 +150,27 @@
<input type="hidden" id="ipWhitelist" name="ipWhitelist" value="">
</div>
<!-- 앱 아이콘 (선택 옵션 — 최하단 배치, compact) -->
<div class="s1-field">
<label class="s1-label">앱 아이콘</label>
<div class="s1-upload-box s1-upload-box--compact" id="iconDropZone">
<img id="iconPreviewImage" class="s1-preview-img" style="display:none;" alt="미리보기">
<div id="iconPlaceholder" class="s1-upload-inner">
<img th:src="@{/img/icon/img_icon.png}">
<p class="s1-upload-title">앱 아이콘 이미지 파일을 업로드 하세요</p>
<p class="s1-upload-hint">권장 사이즈는 512 * 512 픽셀이며 JPG,PNG,GIF 파일만 등록할 수 있습니다.</p>
<button type="button" class="s1-btn-upload"
onclick="document.getElementById('appIconFile').click()">
이미지 파일 업로드
</button>
</div>
<button type="button" class="s1-btn-remove-icon" id="btnRemoveIcon" style="display:none;"
onclick="removeAppIcon()">✕</button>
<input type="file" id="appIconFile" name="appIcon" accept="image/png,image/jpeg,image/jpg,image/gif"
style="display:none;">
</div>
</div>
</form>
</div>
@@ -202,7 +202,8 @@
removeBtn.style.display = 'none';
}
function addIpAddress() {
function addIpAddress(refocus) {
if (refocus === undefined) refocus = true;
const ipInput = document.getElementById('ipWhitelistInput');
const ipValue = ipInput.value.trim();
@@ -225,7 +226,7 @@
ipAddresses.push(ipValue);
renderIpList();
ipInput.value = '';
ipInput.focus();
if (refocus) ipInput.focus();
}
function removeIpAddress(ip) {
@@ -355,9 +356,21 @@
});
}
// IP input Enter key
ipInput.addEventListener('keypress', function (e) {
if (e.key === 'Enter') { e.preventDefault(); addIpAddress(); }
// IP input Enter/Tab key — 추가 버튼을 지나쳐도 입력값을 등록한다.
// Tab 은 다음 tab-stop(추가 버튼)으로 포커스만 이동(onclick 미발동)하므로
// keydown 에서 직접 추가한다. blur 보다 먼저 실행되어 중복 없이 처리된다.
ipInput.addEventListener('keydown', function (e) {
if (e.key === 'Enter') { e.preventDefault(); addIpAddress(); return; }
if (e.key === 'Tab' && ipInput.value.trim()) { addIpAddress(false); }
});
// 포커스 아웃 시 자동 추가/검증 (사용자가 "추가" 버튼을 지나치는 경우 대응).
// "추가" 버튼으로 포커스 이동 시엔 버튼 onclick 이 처리하므로 중복 추가 방지.
ipInput.addEventListener('blur', function (e) {
const rt = e.relatedTarget;
if (rt && rt.closest && rt.closest('.s1-ip-btn--add')) return;
if (!ipInput.value.trim()) return; // 빈 값은 무시
addIpAddress(false); // blur 경로: 재포커스 안 함
});
// Form submit validation
@@ -23,7 +23,7 @@
<div class="step2-wrap">
<!-- Title -->
<h2 class="s2-title">앱생성</h2>
<h2 class="s2-title">클라이언트 정보 - 신규</h2>
<!-- Progress Steps Card -->
<div class="s1-progress-card">
@@ -86,14 +86,14 @@
</svg>
</div>
<span class="s1-step-num">3단계</span>
<span class="s1-step-name">앱 생성 완료</span>
<span class="s1-step-name">클라이언트 신청 완료</span>
</div>
</div>
</div>
<!-- API 선택 공용 모듈 -->
<th:block
th:replace="~{fragment/api_selector :: apiSelector(${apiServices}, ${apiKeyRegistration.selectedApis}, '/myapikey/register/step2', '/myapikey/register/step2/save')}" />
th:replace="~{fragment/api_selector :: apiSelector(${apiServices}, ${apiKeyRegistration.selectedApis}, '/clients/register/step2', '/clients/register/step2/save')}" />
<!-- Bottom Navigation Actions -->
<div class="s2-actions">
@@ -24,7 +24,7 @@
<div class="step3-wrap">
<!-- Title -->
<h2 class="s3-title">앱생성</h2>
<h2 class="s3-title">클라이언트 정보 - 신규</h2>
<!-- Progress Steps Card -->
<div class="s1-progress-card">
@@ -87,7 +87,7 @@
</svg>
</div>
<span class="s1-step-num">3단계</span>
<span class="s1-step-name">앱 생성 완료</span>
<span class="s1-step-name">클라이언트 신청 완료</span>
</div>
</div>
</div>
@@ -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>
@@ -161,7 +161,7 @@
<!-- Bottom Actions -->
<div class="s3-actions">
<a href="/myapikey" class="s3-btn-complete">
<a href="/clients" class="s3-btn-complete">
완료
</a>
</div>
@@ -179,10 +179,10 @@
</p>
</div>
<div class="s3-actions">
<a href="/myapikey/register/step1" class="s3-btn-retry">
<a href="/clients/register/step1" class="s3-btn-retry">
다시 시도
</a>
<a href="/myapikey" class="s3-btn-list">
<a href="/clients" class="s3-btn-list">
목록으로
</a>
</div>
@@ -158,13 +158,13 @@
</div>
<div class="btn_wrap bt_location" th:if="${appRequest.approval.approvalStatus.toString() == 'REQUESTED'}">
<form th:action="@{/myapikey/api_key_request/cancel}" method="post">
<form th:action="@{/clients/api_key_request/cancel}" method="post">
<input type="hidden" name="id" th:value="${appRequest.id}">
<button type="submit" class="btn_del">취소</button>
</form>
</div>
<div class="btn_inventory btn_mtop">
<a th:href="@{/myapikey/api_key_request/history}" class="common_btn_type_1">목록</a>
<a th:href="@{/clients/api_key_request/history}" class="common_btn_type_1">목록</a>
</div>
</div>
</div>
@@ -15,10 +15,10 @@
<div class="tabs">
<ul class="tab_nav tab_bt">
<li class="active">
<a th:href="@{/myapikey/api_key_request/history}">개발</a>
<a th:href="@{/clients/api_key_request/history}">개발</a>
</li>
<li>
<a th:href="@{/myapikey/api_key_request/prod_history}">운영</a>
<a th:href="@{/clients/api_key_request/prod_history}">운영</a>
</li>
</ul>
<div class="tab active">
@@ -49,7 +49,7 @@
<tbody>
<tr th:each="request, status : ${requests}">
<td th:text="${status.index + 1}">1</td>
<td><a th:href="@{/myapikey/api_key_request/detail(id=${request.id})}" class="btn btn-sm btn-primary"
<td><a th:href="@{/clients/api_key_request/detail(id=${request.id})}" class="btn btn-sm btn-primary"
th:text="'[' + ${request.clientName} + '] ' + ${request.type.description}"></a></td>
<td th:text="${request.approval.approvalStatus.description}">1</td>
<td th:text="${#temporals.format(request.approval.createdDate, 'yyyy-MM-dd')}"></td>
@@ -85,7 +85,7 @@
<tbody>
<tr th:each="request, status : ${requests}">
<td th:text="${status.index + 1}">1</td>
<td><a th:href="@{/myapikey/api_key_request/detail(id=${request.id})}" class="btn btn-sm btn-primary" th:text="${request.clientName}"></a></td>
<td><a th:href="@{/clients/api_key_request/detail(id=${request.id})}" class="btn btn-sm btn-primary" th:text="${request.clientName}"></a></td>
<td th:text="${request.approval.approvalStatus}">1</td>
<td th:text="${#temporals.format(request.approval.createdDate, 'yyyy-MM-dd')}"></td>
<td th:text="${#temporals.format(request.approval.approvalDate, 'yyyy-MM-dd')}"></td>
@@ -98,7 +98,7 @@
</div>
</div>
<div class="pagination" th:replace="~{fragment/pagination :: pagination(jsFunction='fn_select_page')}"></div>
<form name="listForm" th:action="@{/myapikey/api_key_request/history}" method="get">
<form name="listForm" th:action="@{/clients/api_key_request/history}" method="get">
<input type="hidden" id="page" name="page"/>
</form>
</div>
@@ -112,7 +112,7 @@
<script>
function fn_select_page(pageNo) {
document.listForm.page.value = pageNo;
document.listForm.action = '[[@{/myapikey/api_key_request/history}]]';
document.listForm.action = '[[@{/clients/api_key_request/history}]]';
document.listForm.submit();
}
</script>
@@ -231,7 +231,7 @@
신청 취소
</button>
<!-- List Button (gray) -->
<a th:href="@{/myapikey}" class="dt-btn-gray">
<a th:href="@{/clients}" class="dt-btn-gray">
목록
</a>
</div>
@@ -289,7 +289,7 @@
$('.loading-overlay').show();
$.ajax({
url: '/myapikey/api_key_request/cancel',
url: '/clients/api_key_request/cancel',
type: 'POST',
data: { id: requestId },
dataType: 'json',
@@ -299,7 +299,7 @@
}).done(function (response) {
if (response.success) {
alert(response.message || '신청이 취소되었습니다.');
window.location.href = /*[[@{/myapikey}]]*/ '/myapikey';
window.location.href = /*[[@{/clients}]]*/ '/clients';
} else {
alert(response.message || '신청 취소 중 오류가 발생했습니다.');
$('.loading-overlay').hide();
@@ -25,7 +25,7 @@
<div class="detail-wrap">
<!-- Title Bar -->
<div class="board-header">
<h2 class="board-title">인증키 정보</h2>
<h2 class="board-title">클라이언트 정보</h2>
<span class="board-desc">등록된 앱의 상세 정보를 확인할 수 있습니다.</span>
</div>
@@ -188,11 +188,11 @@
<!-- 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>
<a th:href="@{/clients}" 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>
th:data-client-id="${apiKey.clientid}" onclick="deleteApiKeyFromButton(this)">API 이용 해지</button>
<a sec:authorize="hasRole('ROLE_API_KEY_REQUEST')" class="dt-btn-blue"
th:href="@{/myapikey/modify/step1(clientId=${apiKey.clientid})}">수정</a>
th:href="@{/clients/modify/step1(clientId=${apiKey.clientid})}">변경 신청</a>
</div>
</div><!-- /detail-wrap -->
@@ -218,43 +218,48 @@
);
}
// Show password prompt for viewing client secret (최초 1회 노출 + 서버측 물리 삭제)
// Client Secret 조회 진입 — 확인 후 조회 (본인 확인은 step-up 2FA)
function showPasswordPrompt() {
customPopups.showPasswordInput({
title: 'Client Secret 조회',
message: '보안을 위해 비밀번호를 입력해주세요.<br>조회 즉시 값은 영구 삭제됩니다.',
onConfirm: function (password) {
$.ajax({
url: /*[[@{/myapikey/credential/reveal-secret}]]*/ '/myapikey/credential/reveal-secret',
type: 'POST',
contentType: 'application/json',
data: JSON.stringify({ clientId: CREDENTIAL_CLIENT_ID, password: password }),
headers: {
'X-XSRF-TOKEN': /*[[${_csrf.token}]]*/ 'token'
}
}).done(function (response) {
if (response.success) {
customPopups.hidePasswordInput();
// 서버가 반환한 secret을 화면에 1회 주입
$('#revealedSecretValue').text(response.secret);
$('#revealedSecretCopyBtn').attr('data-secret', response.secret).data('secret', response.secret);
$('#hiddenSecretBox').hide();
$('#revealedSecretBox').fadeIn(300);
} else if (response.alreadyRevealed) {
customPopups.hidePasswordInput();
showLostKeyGuide();
} else {
customPopups.showPasswordError(response.message || '비밀번호가 일치하지 않습니다.');
}
}).fail(function () {
customPopups.showPasswordError('오류가 발생했습니다. 다시 시도해주세요.');
});
},
onCancel: function () {
// User cancelled - do nothing
customPopups.showConfirm(
'Client Secret<strong>지금 한 번만</strong> 조회할 수 있으며,<br>조회 즉시 값은 <strong>영구 삭제</strong>됩니다.<br><br>조회하시겠습니까?',
function (confirmed) {
if (!confirmed) {
return;
}
doRevealSecret();
}
);
}
// Secret 조회 요청 (step-up 필요 시 2FA 후 동일 요청 재시도)
function doRevealSecret() {
$.ajax({
url: /*[[@{/clients/credential/reveal-secret}]]*/ '/clients/credential/reveal-secret',
type: 'POST',
contentType: 'application/json',
data: JSON.stringify({ clientId: CREDENTIAL_CLIENT_ID }),
headers: {
'X-XSRF-TOKEN': /*[[${_csrf.token}]]*/ 'token'
}
}).done(function (response) {
if (response.success) {
// 서버가 반환한 secret을 화면에 1회 주입
$('#revealedSecretValue').text(response.secret);
$('#revealedSecretCopyBtn').attr('data-secret', response.secret).data('secret', response.secret);
$('#hiddenSecretBox').hide();
$('#revealedSecretBox').fadeIn(300);
} else if (response.alreadyRevealed) {
showLostKeyGuide();
} else {
customPopups.showAlert(response.message || 'Client Secret 조회에 실패했습니다.');
}
}).fail(function (jqXHR) {
if (isStepUpRequired(jqXHR)) {
requireStepUp('/clients/credential/reveal-secret', function () { doRevealSecret(); });
return;
}
customPopups.showAlert('오류가 발생했습니다. 다시 시도해주세요.');
});
}
@@ -307,7 +312,7 @@
$('.loading-overlay').show();
$.ajax({
url: /*[[@{/myapikey/api_key_delete}]]*/ '/myapikey/api_key_delete',
url: /*[[@{/clients/api_key_delete}]]*/ '/clients/api_key_delete',
type: 'POST',
contentType: 'application/json',
data: JSON.stringify({ clientId: clientId }),
@@ -320,11 +325,11 @@
return;
}
customPopups.showAlert(response.msg || 'API Key가 삭제되었습니다.', function () {
window.location.href = /*[[@{/myapikey}]]*/ '/myapikey';
window.location.href = /*[[@{/clients}]]*/ '/clients';
});
}).fail(function (jqXHR, textStatus, errorThrown) {
if (isStepUpRequired(jqXHR)) {
requireStepUp('/myapikey/api_key_delete', function () { doDeleteApiKey(clientId); });
requireStepUp('/clients/api_key_delete', function () { doDeleteApiKey(clientId); });
return;
}
customPopups.showAlert('API 삭제 요청 중 오류가 발생했습니다: ' + errorThrown);
@@ -206,6 +206,15 @@
</div>
</div>
<!-- 회원 구분별 이용 가능 서비스 (TBD) -->
<section class="signup-roles-tbd">
<h2 class="signup-roles-tbd__title">회원 구분별 이용 가능 서비스</h2>
<div class="signup-roles-tbd__placeholder">
<span class="signup-roles-tbd__badge">TBD</span>
<p class="signup-roles-tbd__desc">회원 구분별 이용 가능 서비스 안내가 준비 중입니다.</p>
</div>
</section>
<div class="signup-action">
<a th:href="@{/signup}" class="btn-action-primary">회원가입하러 가기</a>
</div>
@@ -22,6 +22,9 @@
<span th:if="${statsAggregationMinute != null}"
th:text="'통계 데이터는 매시 ' + ${statsAggregationMinute} + '분 집계되며, 집계 이전 시간 기준으로 생성됩니다.'">통계 안내</span>
<span th:if="${statsAggregationMinute == null}">통계 데이터는 매시 집계되며, 집계 이전 시간 기준으로 생성됩니다.</span>
<span class="statistics-notice-example" th:if="${statsAggregationMinute != null}"
th:text="'예) 매시 ' + ${statsAggregationMinute} + '분 집계 기준 — 현재 12:30이면 12:00 이전, 12:05이면 11:00 이전에 발생한 거래가 대상입니다.'">예시</span>
<span class="statistics-notice-example" th:if="${statsAggregationMinute == null}">예) 현재 12:30이면 12:00 이전, 12:05이면 11:00 이전에 발생한 거래가 대상입니다.</span>
<span th:if="${statsLatestTime != null}" class="statistics-notice-latest"
th:text="'※ 최신 집계 시각: ' + ${statsLatestTime} + ' 기준'">최신 집계 시각</span>
</div>
@@ -56,7 +56,7 @@
<div class="s2-selection-container">
<main class="s2-content-area">
<form id="apiSelectorForm" method="post" th:action="@{${formAction}}" class="s2-form"
th:attr="data-save-action=@{${saveAction}}, data-api-required=${!#strings.contains(formAction, '/myapikey/register')}">
th:attr="data-save-action=@{${saveAction}}, data-api-required=${!#strings.contains(formAction, '/clients/register')}">
<!-- Header filter: Search and Select All -->
<div class="s2-filter-header">
@@ -449,7 +449,7 @@
}
$('.loading-overlay').show();
$.ajax({
url: '/myapikey/api_key_request',
url: '/clients/api_key_request',
type: 'POST',
contentType: 'application/json',
data: JSON.stringify(requestData)
@@ -47,6 +47,7 @@
<li><a th:href="@{/partnership}">피드백/개선요청</a></li>
</ul>
</li>
<li><a href="#" class="nav-link">API Status</a></li>
</ul>
</nav>
@@ -82,7 +83,7 @@
<a th:href="@{/users}"><i class="fas fa-users"></i>개발자 관리</a>
</li>
<li sec:authorize="hasRole('ROLE_APP')">
<a th:href="@{/myapikey}"><i class="fas fa-key"></i> 관리</a>
<a th:href="@{/clients}"><i class="fas fa-key"></i>API 신청 관리</a>
<a th:href="@{/webhook}" sec:authorize="hasRole('ROLE_WEBHOOK')"><i class="fas fa-bell"></i>Webhook 관리</a>
<a th:href="@{/statistics/api}"><i class="fas fa-chart-bar"></i>이용 통계</a>
</li>
@@ -228,6 +229,10 @@
<li><a th:href="@{/partnership}">사업 제휴 문의</a></li>
</ul>
</li>
<!-- API Status -->
<li class="drawer-menu-item">
<a href="#" class="drawer-menu-btn">API Status</a>
</li>
<!-- 마이페이지 (Authenticated Only) -->
<li class="drawer-menu-item has-submenu" sec:authorize="isAuthenticated()">
@@ -239,7 +244,7 @@
</button>
<ul class="drawer-submenu">
<li sec:authorize="hasRole('ROLE_CORP_MANAGER')"><a th:href="@{/users}">개발자 관리</a></li>
<li sec:authorize="hasRole('ROLE_APP')"><a th:href="@{/myapikey}"> 관리</a></li>
<li sec:authorize="hasRole('ROLE_APP')"><a th:href="@{/clients}">API 신청 관리</a></li>
<li sec:authorize="hasRole('ROLE_WEBHOOK')"><a th:href="@{/webhook}">Webhook 관리</a></li>
<li sec:authorize="hasRole('ROLE_APP')"><a th:href="@{/statistics/api}">이용 통계</a></li>
<li><a th:href="@{/mypage}">내 정보 관리</a></li>
@@ -1,84 +1,76 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<body>
<!-- Service Left Sidebar Fragment -->
<aside class="service-sidebar" th:fragment="sidebar(activeMenu)">
<nav class="service-nav">
<!-- 서비스 소개 그룹 (Service) -->
<th:block th:if="${activeMenu == 'intro' or activeMenu == 'guide' or activeMenu == 'oauth2' or activeMenu == 'webhookGuide'}">
<a th:href="@{/service/intro}"
th:classappend="${activeMenu == 'intro'} ? 'service-nav__item--active' : ''"
class="service-nav__item">API 포탈 소개</a>
<a th:href="@{/service/guide}"
th:classappend="${activeMenu == 'guide'} ? 'service-nav__item--active' : ''"
class="service-nav__item">회원가입 안내</a>
<a th:href="@{/service/oauth2-guide}"
th:classappend="${activeMenu == 'oauth2'} ? 'service-nav__item--active' : ''"
class="service-nav__item">OAuth2 개발가이드</a>
<a th:href="@{/service/webhook-dev-guide}"
th:classappend="${activeMenu == 'webhookGuide'} ? 'service-nav__item--active' : ''"
class="service-nav__item">웹훅 개발가이드</a>
</th:block>
<!-- Service Left Sidebar Fragment -->
<aside class="service-sidebar" th:fragment="sidebar(activeMenu)">
<nav class="service-nav">
<!-- 서비스 소개 그룹 (Service) -->
<th:block
th:if="${activeMenu == 'intro' or activeMenu == 'guide' or activeMenu == 'oauth2' or activeMenu == 'webhookGuide'}">
<a th:href="@{/service/intro}" th:classappend="${activeMenu == 'intro'} ? 'service-nav__item--active' : ''"
class="service-nav__item">API 포탈 소개</a>
<a th:href="@{/service/guide}" th:classappend="${activeMenu == 'guide'} ? 'service-nav__item--active' : ''"
class="service-nav__item">회원가입 안내</a>
<a th:href="@{/service/oauth2-guide}"
th:classappend="${activeMenu == 'oauth2'} ? 'service-nav__item--active' : ''"
class="service-nav__item">OAuth2 개발가이드</a>
<a th:href="@{/service/webhook-dev-guide}"
th:classappend="${activeMenu == 'webhookGuide'} ? 'service-nav__item--active' : ''"
class="service-nav__item">웹훅 개발가이드</a>
</th:block>
<!-- 고객지원 그룹 (Customer Support) -->
<th:block th:if="${activeMenu == 'notice' or activeMenu == 'faq' or activeMenu == 'qna' or activeMenu == 'feedback'}">
<a th:href="@{/portalnotice}"
th:classappend="${activeMenu == 'notice'} ? 'service-nav__item--active' : ''"
class="service-nav__item">공지사항</a>
<a th:href="@{/faq_list}"
th:classappend="${activeMenu == 'faq'} ? 'service-nav__item--active' : ''"
class="service-nav__item">FAQ</a>
<a th:href="@{/inquiry}"
th:classappend="${activeMenu == 'qna'} ? 'service-nav__item--active' : ''"
class="service-nav__item">Q&A</a>
<a th:href="@{/partnership}"
th:classappend="${activeMenu == 'feedback'} ? 'service-nav__item--active' : ''"
class="service-nav__item">피드백/개선요청</a>
</th:block>
<!-- 고객지원 그룹 (Customer Support) -->
<th:block
th:if="${activeMenu == 'notice' or activeMenu == 'faq' or activeMenu == 'qna' or activeMenu == 'feedback'}">
<a th:href="@{/portalnotice}" th:classappend="${activeMenu == 'notice'} ? 'service-nav__item--active' : ''"
class="service-nav__item">공지사항</a>
<a th:href="@{/faq_list}" th:classappend="${activeMenu == 'faq'} ? 'service-nav__item--active' : ''"
class="service-nav__item">FAQ</a>
<a th:href="@{/inquiry}" th:classappend="${activeMenu == 'qna'} ? 'service-nav__item--active' : ''"
class="service-nav__item">Q&A</a>
<a th:href="@{/partnership}" th:classappend="${activeMenu == 'feedback'} ? 'service-nav__item--active' : ''"
class="service-nav__item">피드백/개선요청</a>
</th:block>
<!-- 마이페이지 그룹 (My Page) -->
<th:block th:if="${activeMenu == 'users' or activeMenu == 'apiKey' or activeMenu == 'statistics' or activeMenu == 'webhook' or activeMenu == 'profile' or activeMenu == 'password'}">
<!-- Profile Section -->
<div class="service-sidebar__profile">
<div class="avatar">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/>
</svg>
</div>
<p class="user-name"><span th:text="${#authentication.principal.userName}">사용자</span></p>
</div>
<!-- 마이페이지 그룹 (My Page) -->
<th:block
th:if="${activeMenu == 'users' or activeMenu == 'apiKey' or activeMenu == 'statistics' or activeMenu == 'webhook' or activeMenu == 'profile' or activeMenu == 'password'}">
<!-- Profile Section -->
<div class="service-sidebar__profile">
<div class="avatar">
<svg viewBox="0 0 24 24" fill="currentColor">
<path
d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" />
</svg>
</div>
<p class="user-name"><span th:text="${#authentication.principal.userName}">사용자</span></p>
</div>
<div class="service-sidebar__divider"></div>
<div class="service-sidebar__divider"></div>
<a th:href="@{/users}"
th:classappend="${activeMenu == 'users'} ? 'service-nav__item--active' : ''"
class="service-nav__item"
sec:authorize="hasRole('ROLE_CORP_MANAGER')">개발자 관리</a>
<a th:href="@{/myapikey}"
th:classappend="${activeMenu == 'apiKey'} ? 'service-nav__item--active' : ''"
class="service-nav__item"
sec:authorize="hasRole('ROLE_APP')">앱 관리</a>
<a th:href="@{/webhook}"
th:classappend="${activeMenu == 'webhook'} ? 'service-nav__item--active' : ''"
class="service-nav__item"
sec:authorize="hasRole('ROLE_WEBHOOK')">Webhook 관리</a>
<a th:href="@{/users}" th:classappend="${activeMenu == 'users'} ? 'service-nav__item--active' : ''"
class="service-nav__item" sec:authorize="hasRole('ROLE_CORP_MANAGER')">개발자 관리</a>
<a th:href="@{/statistics/api}"
th:classappend="${activeMenu == 'statistics'} ? 'service-nav__item--active' : ''"
class="service-nav__item"
sec:authorize="hasRole('ROLE_APP')">이용통계</a>
<a th:href="@{/clients}" th:classappend="${activeMenu == 'apiKey'} ? 'service-nav__item--active' : ''"
class="service-nav__item">API 신청 관리</a>
<a th:href="@{/mypage}"
th:classappend="${activeMenu == 'profile'} ? 'service-nav__item--active' : ''"
class="service-nav__item">내정보 관리</a>
<a th:href="@{/webhook}" th:classappend="${activeMenu == 'webhook'} ? 'service-nav__item--active' : ''"
class="service-nav__item" sec:authorize="hasRole('ROLE_WEBHOOK')">Webhook 관리</a>
<a th:href="@{/password/change}"
th:classappend="${activeMenu == 'password'} ? 'service-nav__item--active' : ''"
class="service-nav__item">비밀번호 변경</a>
</th:block>
</nav>
</aside>
<a th:href="@{/statistics/api}"
th:classappend="${activeMenu == 'statistics'} ? 'service-nav__item--active' : ''"
class="service-nav__item" sec:authorize="hasRole('ROLE_APP')">이용통계</a>
<a th:href="@{/mypage}" th:classappend="${activeMenu == 'profile'} ? 'service-nav__item--active' : ''"
class="service-nav__item">내정보 관리</a>
<a th:href="@{/password/change}"
th:classappend="${activeMenu == 'password'} ? 'service-nav__item--active' : ''"
class="service-nav__item">비밀번호 변경</a>
</th:block>
</nav>
</aside>
</body>
</html>
</html>
@@ -20,7 +20,7 @@
<!-- Modal Body -->
<div class="modal-body">
<p id="passwordPopupMessage" style="text-align: center; margin-bottom: 24px; color: #64748B;">
<p id="passwordPopupMessage" style="text-align: center; margin-bottom: 24px; color: #64748B; word-break: keep-all;">
계속하려면 비밀번호를 입력해주세요.
</p>
@@ -26,5 +26,6 @@
<section layout:fragment="pagePopups"></section>
<script th:src="@{/js/popup/custom-popups.js}"></script>
<script th:src="@{/js/popup/two-factor-auth.js}"></script>
<script th:src="@{/js/djb/toast.js}"></script>
</body>
</html>
@@ -27,5 +27,6 @@
<section layout:fragment="pagePopups"></section>
<script th:src="@{/js/popup/custom-popups.js}"></script>
<script th:src="@{/js/popup/two-factor-auth.js}"></script>
<script th:src="@{/js/djb/toast.js}"></script>
</body>
</html>