클라이언트 생성/수정 흐름 및 관련 UI 개선:
- 클라이언트 등록/수정 URL 구조 리팩토링 (myapikey → clients) - 로그인 직후 클라이언트 미보유 사용자 대상 추가 안내 팝업 - 클라이언트 Secret 조회 및 관련 2FA 단계 로직 개선
This commit is contained in:
@@ -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,7 @@ public class ApiTesterFilter implements Filter {
|
||||
paramMap = extractQueryParams(url);
|
||||
}
|
||||
|
||||
proxyTarget = targetUri;
|
||||
APISender apiSender = ApplicationContextUtil.getContext().getBean(APISender.class);
|
||||
String responseStr;
|
||||
if ("post".equalsIgnoreCase(apiSpecInfoDto.getApiMethod())) {
|
||||
@@ -204,17 +216,23 @@ public class ApiTesterFilter implements Filter {
|
||||
|
||||
} 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 +241,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 +256,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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
+3
-1
@@ -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);
|
||||
|
||||
|
||||
@@ -367,37 +367,37 @@ page:
|
||||
path: "/users/detail"
|
||||
apikey:
|
||||
name: "API 신청 관리"
|
||||
path: "/myapikey"
|
||||
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"
|
||||
|
||||
@@ -7764,9 +7764,8 @@ button.djb-comment-submit:disabled {
|
||||
.tfa-code-row .tfa-code-input-wrap {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: stretch;
|
||||
height: 56px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid #BDC7CF;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
@@ -7778,6 +7777,8 @@ button.djb-comment-submit:disabled {
|
||||
.tfa-code-row .tfa-code-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
padding: 0 14px;
|
||||
border: 0;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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();
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
* 추가로, 페이지 진입 시 URL 쿼리 `apiApplyToast` 값에 따라 안내 토스트를 자동 노출한다.
|
||||
* - new : 신규 클라이언트 생성 페이지 진입 안내
|
||||
* - existing : 클라이언트 목록(선택) 진입 안내
|
||||
* - modify : 클라이언트 1건 보유 → 수정 플로우 API 선택 직행 안내
|
||||
* 토스트 노출 후 쿼리를 정리(replaceState)해 새로고침 시 재노출을 막는다.
|
||||
*/
|
||||
(function (global) {
|
||||
@@ -54,7 +55,8 @@
|
||||
// ── URL 쿼리 기반 자동 안내 토스트 ──
|
||||
var API_APPLY_MESSAGES = {
|
||||
"new": "API 를 사용할 클라이언트 생성을 먼저 진행해주세요.",
|
||||
"existing": "API 를 추가 사용할 클라이언트를 선택해주세요"
|
||||
"existing": "API 를 추가 사용할 클라이언트를 선택해주세요",
|
||||
"modify": "보유 클라이언트에 추가 사용할 API 를 선택해주세요"
|
||||
};
|
||||
|
||||
function handleApiApplyToast() {
|
||||
|
||||
@@ -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: '파라미터' },
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -281,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);
|
||||
@@ -421,7 +423,7 @@
|
||||
|
||||
// ===== API 사용 신청: 회원 구분별 분기 (API 신청 절차) =====
|
||||
// 비회원/개인회원 → 법인회원 안내 팝업 → 회원가입 안내 이동
|
||||
// 법인이용자 → 클라이언트 현황: 신규(앱 0건)→신규 클라이언트 / 기존(앱 있음)→클라이언트 관리
|
||||
// 법인이용자 → 클라이언트 현황: 신규(0건)→신규 클라이언트 / 기존(1건)→클라이언트 수정의 API 선택 직행 / 기존(1건 초과)→클라이언트 관리
|
||||
function requestApiUse() {
|
||||
fetch(/*[[@{/djb/testbed/auth/context}]]*/ '/djb/testbed/auth/context')
|
||||
.then(function (r) { return r.json(); })
|
||||
@@ -438,16 +440,24 @@
|
||||
} else { go(); }
|
||||
return;
|
||||
}
|
||||
// 법인이용자: 보유 클라이언트 유무로 신규/기존 분기 (이동 페이지에서 Toast 안내)
|
||||
const hasClients = ctx && ctx.credentials && ctx.credentials.length > 0;
|
||||
if (hasClients) {
|
||||
// 기존 → 클라이언트 목록(선택) + Toast
|
||||
window.location.href = /*[[@{/myapikey(apiApplyToast='existing')}]]*/ '/myapikey?apiApplyToast=existing';
|
||||
// 법인이용자: 보유 클라이언트 수로 신규/기존 분기 (이동 페이지에서 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 = /*[[@{/myapikey/register/step1(clear=true,apiApplyToast='new')}]]*/ '/myapikey/register/step1?clear=true&apiApplyToast=new';
|
||||
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(
|
||||
@@ -458,7 +468,7 @@
|
||||
})
|
||||
.catch(function (e) {
|
||||
console.error('API 사용 신청 컨텍스트 조회 실패', e);
|
||||
window.location.href = /*[[@{/myapikey}]]*/ '/myapikey';
|
||||
window.location.href = /*[[@{/clients}]]*/ '/clients';
|
||||
});
|
||||
}
|
||||
Array.prototype.forEach.call(document.querySelectorAll('.api-apply-btn'), function (btn) {
|
||||
@@ -533,33 +543,119 @@
|
||||
.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',
|
||||
@@ -567,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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -584,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/앱 인증 컨텍스트 초기화
|
||||
|
||||
@@ -328,7 +328,7 @@
|
||||
</p>
|
||||
<div class="action-buttons">
|
||||
<a th:href="@{/service/intro}" 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="@{/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 }]]
|
||||
});
|
||||
|
||||
@@ -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,7 +84,7 @@
|
||||
|
||||
<!-- 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 -->
|
||||
@@ -173,7 +173,7 @@
|
||||
|
||||
<!-- 다음 버튼 -->
|
||||
<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>
|
||||
|
||||
@@ -349,8 +349,12 @@
|
||||
if (!ipInput.value.trim()) return; // 빈 값은 무시
|
||||
addIpAddress(false); // blur 경로: 재포커스 안 함
|
||||
});
|
||||
ipInput.addEventListener('keypress', function (e) {
|
||||
if (e.key === 'Enter') { e.preventDefault(); addIpAddress(); }
|
||||
// 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -97,7 +97,7 @@
|
||||
|
||||
<!-- 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">
|
||||
|
||||
<!-- 앱 이름 -->
|
||||
@@ -356,9 +356,12 @@
|
||||
});
|
||||
}
|
||||
|
||||
// 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); }
|
||||
});
|
||||
|
||||
// 포커스 아웃 시 자동 추가/검증 (사용자가 "추가" 버튼을 지나치는 경우 대응).
|
||||
|
||||
@@ -93,7 +93,7 @@
|
||||
|
||||
<!-- 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">
|
||||
|
||||
@@ -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)">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);
|
||||
|
||||
@@ -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>API 신청 관리</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}">API 신청 관리</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>
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
class="service-nav__item"
|
||||
sec:authorize="hasRole('ROLE_CORP_MANAGER')">개발자 관리</a>
|
||||
|
||||
<a th:href="@{/myapikey}"
|
||||
<a th:href="@{/clients}"
|
||||
th:classappend="${activeMenu == 'apiKey'} ? 'service-nav__item--active' : ''"
|
||||
class="service-nav__item">API 신청 관리</a>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user