사용자 Secret 관리 기능 추가:
- 사용자 지정 Secret 필드 추가(WebhookRequest, DTO, Mapper) - 사용자 Secret 평문 조회/갱신 로직 및 비밀번호 재인증 처리 - Secret 입력 UI 개선 및 스타일 수정
This commit is contained in:
+66
-19
@@ -3,6 +3,7 @@ package com.eactive.apim.portal.djb.webhook.controller;
|
|||||||
import com.eactive.apim.portal.apps.apiservice.dto.ApiGroupSearch;
|
import com.eactive.apim.portal.apps.apiservice.dto.ApiGroupSearch;
|
||||||
import com.eactive.apim.portal.apps.app.service.AppServiceFacade;
|
import com.eactive.apim.portal.apps.app.service.AppServiceFacade;
|
||||||
import com.eactive.apim.portal.apps.apiservice.service.ApiServiceService;
|
import com.eactive.apim.portal.apps.apiservice.service.ApiServiceService;
|
||||||
|
import com.eactive.apim.portal.apps.session.service.UserSessionService;
|
||||||
import com.eactive.apim.portal.common.exception.UserErrorMessageResolver;
|
import com.eactive.apim.portal.common.exception.UserErrorMessageResolver;
|
||||||
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
||||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||||
@@ -14,10 +15,15 @@ import java.util.HashMap;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
import javax.servlet.http.HttpServletRequest;
|
||||||
|
import javax.servlet.http.HttpServletResponse;
|
||||||
|
import javax.servlet.http.HttpSession;
|
||||||
import javax.validation.Valid;
|
import javax.validation.Valid;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.security.access.annotation.Secured;
|
import org.springframework.security.access.annotation.Secured;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
|
import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler;
|
||||||
import org.springframework.stereotype.Controller;
|
import org.springframework.stereotype.Controller;
|
||||||
import org.springframework.ui.Model;
|
import org.springframework.ui.Model;
|
||||||
import org.springframework.validation.BindingResult;
|
import org.springframework.validation.BindingResult;
|
||||||
@@ -50,10 +56,16 @@ public class WebhookController {
|
|||||||
|
|
||||||
private static final int TOTAL_STEPS = 3;
|
private static final int TOTAL_STEPS = 3;
|
||||||
|
|
||||||
|
/** 비밀번호 재인증 연속 실패 허용 횟수. 초과 시 세션 종료(강제 로그아웃) — StepUpPasswordController 답습. */
|
||||||
|
private static final int MAX_PW_FAIL_COUNT = 5;
|
||||||
|
/** 연속 실패 횟수 세션 attribute 키 */
|
||||||
|
private static final String ATTR_PW_FAIL_COUNT = "WEBHOOK_PW_CONFIRM_FAIL_COUNT";
|
||||||
|
|
||||||
private final WebhookService webhookService;
|
private final WebhookService webhookService;
|
||||||
private final WebhookEventTypeProvider eventTypeProvider;
|
private final WebhookEventTypeProvider eventTypeProvider;
|
||||||
private final ApiServiceService apiServiceService;
|
private final ApiServiceService apiServiceService;
|
||||||
private final AppServiceFacade appServiceFacade;
|
private final AppServiceFacade appServiceFacade;
|
||||||
|
private final UserSessionService userSessionService;
|
||||||
|
|
||||||
@ModelAttribute("webhookRegistration")
|
@ModelAttribute("webhookRegistration")
|
||||||
public WebhookRegistrationDTO webhookRegistration() {
|
public WebhookRegistrationDTO webhookRegistration() {
|
||||||
@@ -211,6 +223,7 @@ public class WebhookController {
|
|||||||
|
|
||||||
ModelAndView mav = new ModelAndView("apps/webhook/webhookModifyStep1");
|
ModelAndView mav = new ModelAndView("apps/webhook/webhookModifyStep1");
|
||||||
mav.addObject("eventTypes", eventTypeProvider.getAll());
|
mav.addObject("eventTypes", eventTypeProvider.getAll());
|
||||||
|
mav.addObject("userSecretSet", webhook.getUserSecretMasked() != null && !webhook.getUserSecretMasked().isEmpty());
|
||||||
addStepModel(mav, 1);
|
addStepModel(mav, 1);
|
||||||
return mav;
|
return mav;
|
||||||
}
|
}
|
||||||
@@ -297,33 +310,35 @@ public class WebhookController {
|
|||||||
|
|
||||||
@PostMapping("/verify-secret")
|
@PostMapping("/verify-secret")
|
||||||
@ResponseBody
|
@ResponseBody
|
||||||
public Map<String, Object> verifySecret(@RequestParam String password) {
|
public Map<String, Object> verifySecret(@RequestParam String password,
|
||||||
Map<String, Object> result = new HashMap<>();
|
HttpSession session, HttpServletRequest request, HttpServletResponse response) {
|
||||||
if (!verifyPassword(password)) {
|
Map<String, Object> failResult = checkPassword(password, session, request, response);
|
||||||
result.put("success", false);
|
if (failResult != null) {
|
||||||
result.put("message", "비밀번호가 일치하지 않습니다.");
|
return failResult;
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
Map<String, Object> result = new HashMap<>();
|
||||||
Optional<WebhookDTO> webhook = webhookService.getByOrg(currentOrgId());
|
Optional<WebhookDTO> webhook = webhookService.getByOrg(currentOrgId());
|
||||||
if (!webhook.isPresent()) {
|
if (!webhook.isPresent()) {
|
||||||
result.put("success", false);
|
result.put("success", false);
|
||||||
result.put("message", "등록된 Webhook이 없습니다.");
|
result.put("message", "등록된 Webhook이 없습니다.");
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
Long webhookId = webhook.get().getId();
|
||||||
result.put("success", true);
|
result.put("success", true);
|
||||||
result.put("secret", webhookService.getPlainSecret(webhook.get().getId(), currentOrgId()));
|
result.put("secret", webhookService.getPlainSecret(webhookId, currentOrgId()));
|
||||||
|
result.put("userSecret", webhookService.getPlainUserSecret(webhookId, currentOrgId()));
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/regenerate-secret")
|
@PostMapping("/regenerate-secret")
|
||||||
@ResponseBody
|
@ResponseBody
|
||||||
public Map<String, Object> regenerateSecret(@RequestParam String password) {
|
public Map<String, Object> regenerateSecret(@RequestParam String password,
|
||||||
Map<String, Object> result = new HashMap<>();
|
HttpSession session, HttpServletRequest request, HttpServletResponse response) {
|
||||||
if (!verifyPassword(password)) {
|
Map<String, Object> failResult = checkPassword(password, session, request, response);
|
||||||
result.put("success", false);
|
if (failResult != null) {
|
||||||
result.put("message", "비밀번호가 일치하지 않습니다.");
|
return failResult;
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
Map<String, Object> result = new HashMap<>();
|
||||||
Optional<WebhookDTO> webhook = webhookService.getByOrg(currentOrgId());
|
Optional<WebhookDTO> webhook = webhookService.getByOrg(currentOrgId());
|
||||||
if (!webhook.isPresent()) {
|
if (!webhook.isPresent()) {
|
||||||
result.put("success", false);
|
result.put("success", false);
|
||||||
@@ -338,13 +353,13 @@ public class WebhookController {
|
|||||||
|
|
||||||
@PostMapping("/delete")
|
@PostMapping("/delete")
|
||||||
@ResponseBody
|
@ResponseBody
|
||||||
public Map<String, Object> delete(@RequestParam String password) {
|
public Map<String, Object> delete(@RequestParam String password,
|
||||||
Map<String, Object> result = new HashMap<>();
|
HttpSession session, HttpServletRequest request, HttpServletResponse response) {
|
||||||
if (!verifyPassword(password)) {
|
Map<String, Object> failResult = checkPassword(password, session, request, response);
|
||||||
result.put("success", false);
|
if (failResult != null) {
|
||||||
result.put("message", "비밀번호가 일치하지 않습니다.");
|
return failResult;
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
Map<String, Object> result = new HashMap<>();
|
||||||
Optional<WebhookDTO> webhook = webhookService.getByOrg(currentOrgId());
|
Optional<WebhookDTO> webhook = webhookService.getByOrg(currentOrgId());
|
||||||
if (!webhook.isPresent()) {
|
if (!webhook.isPresent()) {
|
||||||
result.put("success", false);
|
result.put("success", false);
|
||||||
@@ -376,6 +391,38 @@ public class WebhookController {
|
|||||||
return appServiceFacade.verifyUserPassword(user, password);
|
return appServiceFacade.verifyUserPassword(user, password);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 비밀번호 재인증 공통 체크. 성공(및 카운트 초기화) 시 {@code null}, 실패 시 즉시 응답할 결과 Map 을 반환한다.
|
||||||
|
* 연속 실패가 {@link #MAX_PW_FAIL_COUNT} 회 이상이면 세션을 강제 종료하고 {@code forceLogout=true} 를 담는다
|
||||||
|
* (무차별 대입 방어 — {@code StepUpPasswordController} 답습).
|
||||||
|
*/
|
||||||
|
private Map<String, Object> checkPassword(String password, HttpSession session,
|
||||||
|
HttpServletRequest request, HttpServletResponse response) {
|
||||||
|
if (verifyPassword(password)) {
|
||||||
|
session.removeAttribute(ATTR_PW_FAIL_COUNT);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Integer count = (Integer) session.getAttribute(ATTR_PW_FAIL_COUNT);
|
||||||
|
int failCount = (count == null ? 0 : count) + 1;
|
||||||
|
session.setAttribute(ATTR_PW_FAIL_COUNT, failCount);
|
||||||
|
|
||||||
|
Map<String, Object> result = new HashMap<>();
|
||||||
|
result.put("success", false);
|
||||||
|
if (failCount >= MAX_PW_FAIL_COUNT) {
|
||||||
|
userSessionService.removeSession(session.getId());
|
||||||
|
new SecurityContextLogoutHandler().logout(request, response,
|
||||||
|
SecurityContextHolder.getContext().getAuthentication());
|
||||||
|
result.put("forceLogout", true);
|
||||||
|
result.put("message", "비밀번호 확인 5회 실패로 로그아웃되었습니다.");
|
||||||
|
log.warn("Webhook 비밀번호 재인증 5회 실패로 강제 로그아웃 loginId={}", SecurityUtil.getCurrentLoginId());
|
||||||
|
} else {
|
||||||
|
result.put("message",
|
||||||
|
"비밀번호가 일치하지 않습니다. (실패 " + failCount + "/" + MAX_PW_FAIL_COUNT + "회, 초과 시 자동 로그아웃됩니다)");
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
private String currentOrgId() {
|
private String currentOrgId() {
|
||||||
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
|
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
|
||||||
return user != null && user.getPortalOrg() != null ? user.getPortalOrg().getId() : null;
|
return user != null && user.getPortalOrg() != null ? user.getPortalOrg().getId() : null;
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ public class WebhookDTO implements Serializable {
|
|||||||
private Long id;
|
private Long id;
|
||||||
private String targetUrl;
|
private String targetUrl;
|
||||||
private String secretMasked;
|
private String secretMasked;
|
||||||
|
private String userSecretMasked;
|
||||||
private String createdDate;
|
private String createdDate;
|
||||||
|
|
||||||
/** 구독 API ID 목록(폼 prefill 등 내부용). */
|
/** 구독 API ID 목록(폼 prefill 등 내부용). */
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.eactive.apim.portal.djb.webhook.dto;
|
|||||||
import java.io.Serializable;
|
import java.io.Serializable;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import javax.validation.constraints.Pattern;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
import org.hibernate.validator.constraints.Length;
|
import org.hibernate.validator.constraints.Length;
|
||||||
import org.hibernate.validator.constraints.NotBlank;
|
import org.hibernate.validator.constraints.NotBlank;
|
||||||
@@ -27,6 +28,15 @@ public class WebhookRegistrationDTO implements Serializable {
|
|||||||
@Length(max = 255, message = "URL은 255자를 초과할 수 없습니다.")
|
@Length(max = 255, message = "URL은 255자를 초과할 수 없습니다.")
|
||||||
private String targetUrl;
|
private String targetUrl;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용자 지정 Secret(선택). Webhook 발송 시 HTTP 요청 헤더 값으로 그대로 echo 되므로
|
||||||
|
* 출력 가능 ASCII(0x20~0x7E)만 허용한다 — 헤더는 non-ASCII/개행을 담을 수 없다.
|
||||||
|
* 수정 시 공란이면 기존 값을 유지한다({@code WebhookService#update} 참조).
|
||||||
|
*/
|
||||||
|
@Length(max = 500, message = "값은 500자를 초과할 수 없습니다.")
|
||||||
|
@Pattern(regexp = "^[\\x20-\\x7E]*$", message = "영문·숫자·특수문자 등 ASCII 문자만 입력 가능합니다(한글 등 유니코드 문자는 사용할 수 없습니다).")
|
||||||
|
private String userSecret;
|
||||||
|
|
||||||
/** Step1: 구독 EventType 코드 목록 (TSEAIRM28 EVENT_TYPE). */
|
/** Step1: 구독 EventType 코드 목록 (TSEAIRM28 EVENT_TYPE). */
|
||||||
private List<String> eventTypes = new ArrayList<>();
|
private List<String> eventTypes = new ArrayList<>();
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import org.mapstruct.Mapping;
|
|||||||
public interface WebhookMapper {
|
public interface WebhookMapper {
|
||||||
|
|
||||||
@Mapping(target = "secretMasked", ignore = true)
|
@Mapping(target = "secretMasked", ignore = true)
|
||||||
|
@Mapping(target = "userSecretMasked", ignore = true)
|
||||||
@Mapping(target = "apiIds", ignore = true)
|
@Mapping(target = "apiIds", ignore = true)
|
||||||
@Mapping(target = "eventTypes", ignore = true)
|
@Mapping(target = "eventTypes", ignore = true)
|
||||||
WebhookDTO toDto(WebhookRequest entity);
|
WebhookDTO toDto(WebhookRequest entity);
|
||||||
|
|||||||
+4
@@ -48,6 +48,10 @@ public class WebhookRequest implements Serializable {
|
|||||||
@Column(name = "SECRET", length = 500)
|
@Column(name = "SECRET", length = 500)
|
||||||
private String secret;
|
private String secret;
|
||||||
|
|
||||||
|
/** 사용자가 지정한 값. admin 발송 시 요청 헤더에 그대로 echo 된다 — SECRET 과 동일 이유로 평문 저장. */
|
||||||
|
@Column(name = "USER_SECRET", length = 500)
|
||||||
|
private String userSecret;
|
||||||
|
|
||||||
@Column(name = "CREATED_BY", length = 200)
|
@Column(name = "CREATED_BY", length = 200)
|
||||||
private String createdBy;
|
private String createdBy;
|
||||||
|
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ public class WebhookService {
|
|||||||
request.setOrgId(orgId);
|
request.setOrgId(orgId);
|
||||||
request.setTargetUrl(dto.getTargetUrl().trim());
|
request.setTargetUrl(dto.getTargetUrl().trim());
|
||||||
request.setSecret(secret);
|
request.setSecret(secret);
|
||||||
|
request.setUserSecret(normalizeUserSecret(dto.getUserSecret()));
|
||||||
WebhookRequest saved = requestRepository.save(request);
|
WebhookRequest saved = requestRepository.save(request);
|
||||||
|
|
||||||
persistChildren(saved.getId(), dto);
|
persistChildren(saved.getId(), dto);
|
||||||
@@ -83,12 +84,17 @@ public class WebhookService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* URL/API/EventType 수정. Secret 은 보존한다. 연관 테이블은 delete-all 후 재삽입.
|
* URL/API/EventType 수정. Secret 은 보존한다. 연관 테이블은 delete-all 후 재삽입.
|
||||||
|
* userSecret 은 공란으로 제출되면 기존 값을 유지한다(마스킹 표시라 재입력 없이는 원본을 알 수 없으므로).
|
||||||
*/
|
*/
|
||||||
public WebhookDTO update(Long id, WebhookRegistrationDTO dto, String orgId) {
|
public WebhookDTO update(Long id, WebhookRegistrationDTO dto, String orgId) {
|
||||||
WebhookRequest request = loadOwned(id, orgId);
|
WebhookRequest request = loadOwned(id, orgId);
|
||||||
validate(dto);
|
validate(dto);
|
||||||
|
|
||||||
request.setTargetUrl(dto.getTargetUrl().trim());
|
request.setTargetUrl(dto.getTargetUrl().trim());
|
||||||
|
String userSecret = normalizeUserSecret(dto.getUserSecret());
|
||||||
|
if (userSecret != null) {
|
||||||
|
request.setUserSecret(userSecret);
|
||||||
|
}
|
||||||
requestRepository.save(request);
|
requestRepository.save(request);
|
||||||
|
|
||||||
apiRepository.deleteByWebhookReqId(id);
|
apiRepository.deleteByWebhookReqId(id);
|
||||||
@@ -132,6 +138,14 @@ public class WebhookService {
|
|||||||
return loadOwned(id, orgId).getSecret();
|
return loadOwned(id, orgId).getSecret();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 평문 사용자 지정 Secret 조회. 컨트롤러에서 비밀번호 재인증 후에만 호출한다.
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public String getPlainUserSecret(Long id, String orgId) {
|
||||||
|
return loadOwned(id, orgId).getUserSecret();
|
||||||
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------------
|
// ----------------------------------------------------------------
|
||||||
|
|
||||||
private WebhookRequest loadOwned(Long id, String orgId) {
|
private WebhookRequest loadOwned(Long id, String orgId) {
|
||||||
@@ -168,6 +182,14 @@ public class WebhookService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String normalizeUserSecret(String raw) {
|
||||||
|
if (raw == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String trimmed = raw.trim();
|
||||||
|
return trimmed.isEmpty() ? null : trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
private List<String> dedup(List<String> values) {
|
private List<String> dedup(List<String> values) {
|
||||||
if (values == null) {
|
if (values == null) {
|
||||||
return java.util.Collections.emptyList();
|
return java.util.Collections.emptyList();
|
||||||
@@ -178,6 +200,8 @@ public class WebhookService {
|
|||||||
private WebhookDTO toDetailDto(WebhookRequest request) {
|
private WebhookDTO toDetailDto(WebhookRequest request) {
|
||||||
WebhookDTO dto = webhookMapper.toDto(request);
|
WebhookDTO dto = webhookMapper.toDto(request);
|
||||||
dto.setSecretMasked(request.getSecret() == null ? "" : SECRET_MASK);
|
dto.setSecretMasked(request.getSecret() == null ? "" : SECRET_MASK);
|
||||||
|
dto.setUserSecretMasked(request.getUserSecret() == null || request.getUserSecret().isEmpty()
|
||||||
|
? "" : SECRET_MASK);
|
||||||
|
|
||||||
List<String> apiIds = apiRepository.findByWebhookReqId(request.getId()).stream()
|
List<String> apiIds = apiRepository.findByWebhookReqId(request.getId()).stream()
|
||||||
.map(WebhookRequestApi::getApiId)
|
.map(WebhookRequestApi::getApiId)
|
||||||
|
|||||||
@@ -26410,7 +26410,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
|||||||
}
|
}
|
||||||
.step1-wrap .s1-form-card .webhook-info-group .secret-action-row {
|
.step1-wrap .s1-form-card .webhook-info-group .secret-action-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
@@ -26421,24 +26421,24 @@ input[type=checkbox]:checked + .custom-checkbox {
|
|||||||
}
|
}
|
||||||
.step1-wrap .s1-form-card .webhook-info-group .secret-box {
|
.step1-wrap .s1-form-card .webhook-info-group .secret-box {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
max-width: 250px;
|
min-width: 0;
|
||||||
height: 48px;
|
min-height: 48px;
|
||||||
background-color: #efefef;
|
background-color: #efefef;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
padding: 0 20px;
|
padding: 12px 20px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
font-size: 13px;
|
||||||
font-size: 14px;
|
line-height: 1.4;
|
||||||
color: #4e5968;
|
color: #4e5968;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
letter-spacing: 1px;
|
word-break: break-all;
|
||||||
|
white-space: normal;
|
||||||
border: 1px solid #DFDFDF;
|
border: 1px solid #DFDFDF;
|
||||||
}
|
}
|
||||||
@media (max-width: 576px) {
|
@media (max-width: 576px) {
|
||||||
.step1-wrap .s1-form-card .webhook-info-group .secret-box {
|
.step1-wrap .s1-form-card .webhook-info-group .secret-box {
|
||||||
max-width: 100%;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
flex: none;
|
flex: none;
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -8,11 +8,13 @@
|
|||||||
* - 기선택: window.API_SELECTOR_SELECTED / 목록 URL: window.API_SELECTOR_LIST_URL (fragment 인라인 주입)
|
* - 기선택: window.API_SELECTOR_SELECTED / 목록 URL: window.API_SELECTOR_LIST_URL (fragment 인라인 주입)
|
||||||
* - "이전" 버튼: 호출 페이지의 #btnPrevStep (없으면 스킵)
|
* - "이전" 버튼: 호출 페이지의 #btnPrevStep (없으면 스킵)
|
||||||
* - 카트/모달: fragment `apiSelectorPopups` 를 pagePopups 슬롯에서 호출(body 직속)
|
* - 카트/모달: fragment `apiSelectorPopups` 를 pagePopups 슬롯에서 호출(body 직속)
|
||||||
|
* - 페이징: #apiPagination (PAGE_SIZE 건/페이지) — 카테고리/검색은 재조회 없이 클라이언트에서 처리
|
||||||
*
|
*
|
||||||
* design(figma s2) 인라인 스크립트 대비 패치 3건:
|
* design(figma s2) 인라인 스크립트 대비 패치 4건:
|
||||||
* 1) 모달 열 때마다 updateModalList() 재빌드 — 세션 복원 직후(카드 렌더 전) 빈 모달 방지
|
* 1) 모달 열 때마다 updateModalList() 재빌드 — 세션 복원 직후(카드 렌더 전) 빈 모달 방지
|
||||||
* 2) 모달 리스트를 DOM 체크박스가 아닌 selectedApis Set 기준으로 생성 — 미렌더/타 카테고리 누락 방지
|
* 2) 모달 리스트를 DOM 체크박스가 아닌 selectedApis Set 기준으로 생성 — 미렌더/타 카테고리 누락 방지
|
||||||
* 3) 제출/이전 시 DOM에 없는 선택분을 hidden input으로 주입 — 카테고리 필터 상태 전송 유실 방지
|
* 3) 제출/이전 시 DOM에 없는 선택분을 hidden input으로 주입 — 카테고리 필터 상태 전송 유실 방지
|
||||||
|
* 4) 클라이언트 페이징 — 카드는 현재 페이지분만 DOM 렌더, 검색/전체선택/모달은 필터된 전체 목록 기준으로 동작
|
||||||
*/
|
*/
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
const form = document.getElementById('apiSelectorForm');
|
const form = document.getElementById('apiSelectorForm');
|
||||||
@@ -20,16 +22,21 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
return; // 모듈 미사용 페이지
|
return; // 모듈 미사용 페이지
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PAGE_SIZE = 12;
|
||||||
|
|
||||||
// DOM Elements
|
// DOM Elements
|
||||||
const searchInput = document.getElementById('apiSearch');
|
const searchInput = document.getElementById('apiSearch');
|
||||||
const menuTitles = document.querySelectorAll('.s2-category-tab');
|
const menuTitles = document.querySelectorAll('.s2-category-tab');
|
||||||
const apiCardGrid = document.getElementById('apiCardGrid');
|
const apiCardGrid = document.getElementById('apiCardGrid');
|
||||||
const loadingState = document.getElementById('loadingState');
|
const loadingState = document.getElementById('loadingState');
|
||||||
const emptyState = document.getElementById('emptyState');
|
const emptyState = document.getElementById('emptyState');
|
||||||
|
const paginationEl = document.getElementById('apiPagination');
|
||||||
|
|
||||||
let currentFilter = ''; // Empty string means "all"
|
let currentFilter = ''; // Empty string means "all"
|
||||||
let currentServiceName = '전체';
|
let currentServiceName = '전체';
|
||||||
let allApis = [];
|
let allApis = []; // 현재 카테고리 조회 결과 전체
|
||||||
|
let filteredApis = []; // allApis 에 검색어까지 적용한 결과(페이징 대상)
|
||||||
|
let currentPage = 1;
|
||||||
let selectedApis = new Set();
|
let selectedApis = new Set();
|
||||||
|
|
||||||
// Restore selected APIs from session (fragment 인라인 주입)
|
// Restore selected APIs from session (fragment 인라인 주입)
|
||||||
@@ -44,8 +51,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
function loadApis(groupId) {
|
function loadApis(groupId) {
|
||||||
loadingState.style.display = 'block';
|
loadingState.style.display = 'block';
|
||||||
emptyState.style.display = 'none';
|
emptyState.style.display = 'none';
|
||||||
|
clearCards();
|
||||||
document.querySelectorAll('.s2-api-card').forEach(card => card.remove());
|
|
||||||
|
|
||||||
const baseUrl = window.API_SELECTOR_LIST_URL || '/apis/for_request';
|
const baseUrl = window.API_SELECTOR_LIST_URL || '/apis/for_request';
|
||||||
let url = baseUrl;
|
let url = baseUrl;
|
||||||
@@ -56,16 +62,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
fetch(url).then(response => response.json()).then(apis => {
|
fetch(url).then(response => response.json()).then(apis => {
|
||||||
allApis = apis;
|
allApis = apis;
|
||||||
loadingState.style.display = 'none';
|
loadingState.style.display = 'none';
|
||||||
|
applySearch();
|
||||||
if (apis.length === 0) {
|
|
||||||
emptyState.style.display = 'block';
|
|
||||||
document.getElementById('apiResultCount').textContent = '0';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('apiResultCount').textContent = apis.length;
|
|
||||||
renderApiCards(apis);
|
|
||||||
updateSelectAllUI();
|
|
||||||
}).catch(error => {
|
}).catch(error => {
|
||||||
console.error('Failed to load APIs:', error);
|
console.error('Failed to load APIs:', error);
|
||||||
loadingState.style.display = 'none';
|
loadingState.style.display = 'none';
|
||||||
@@ -73,10 +70,49 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
emptyState.querySelector('p').textContent = '다시 시도해주세요.';
|
emptyState.querySelector('p').textContent = '다시 시도해주세요.';
|
||||||
emptyState.style.display = 'block';
|
emptyState.style.display = 'block';
|
||||||
document.getElementById('apiResultCount').textContent = '0';
|
document.getElementById('apiResultCount').textContent = '0';
|
||||||
|
renderPagination(0);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render API cards
|
// 렌더된 카드만 제거(로딩/빈 상태 엘리먼트는 유지)
|
||||||
|
function clearCards() {
|
||||||
|
document.querySelectorAll('.s2-api-card').forEach(card => card.remove());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 검색어 기준으로 allApis → filteredApis 재계산 후 1페이지부터 렌더
|
||||||
|
function applySearch() {
|
||||||
|
const term = searchInput ? searchInput.value.toLowerCase().trim() : '';
|
||||||
|
filteredApis = !term ? allApis : allApis.filter(function(api) {
|
||||||
|
const name = (api.apiName || '').toLowerCase();
|
||||||
|
const desc = (api.apiSimpleDescription || '').toLowerCase();
|
||||||
|
return name.includes(term) || desc.includes(term);
|
||||||
|
});
|
||||||
|
goToPage(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 지정 페이지로 이동 — 해당 페이지분만 렌더(재조회 없음)
|
||||||
|
function goToPage(page) {
|
||||||
|
const totalPages = Math.max(1, Math.ceil(filteredApis.length / PAGE_SIZE));
|
||||||
|
currentPage = Math.min(Math.max(1, page), totalPages);
|
||||||
|
|
||||||
|
clearCards();
|
||||||
|
document.getElementById('apiResultCount').textContent = filteredApis.length;
|
||||||
|
|
||||||
|
if (filteredApis.length === 0) {
|
||||||
|
emptyState.style.display = 'block';
|
||||||
|
renderPagination(0);
|
||||||
|
updateSelectAllUI();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emptyState.style.display = 'none';
|
||||||
|
|
||||||
|
const start = (currentPage - 1) * PAGE_SIZE;
|
||||||
|
renderApiCards(filteredApis.slice(start, start + PAGE_SIZE));
|
||||||
|
renderPagination(filteredApis.length);
|
||||||
|
updateSelectAllUI();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render API cards (현재 페이지분)
|
||||||
function renderApiCards(apis) {
|
function renderApiCards(apis) {
|
||||||
const fragment = document.createDocumentFragment();
|
const fragment = document.createDocumentFragment();
|
||||||
|
|
||||||
@@ -207,24 +243,25 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
updateSelectAllCheckboxState();
|
updateSelectAllCheckboxState();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update select all checkbox state based on visible cards
|
// Update select all checkbox state — 현재 페이지가 아닌 필터된 전체 목록 기준(페이징 무관)
|
||||||
function updateSelectAllCheckboxState() {
|
function updateSelectAllCheckboxState() {
|
||||||
const selectAllCheckbox = document.getElementById('selectAllCheckbox');
|
const selectAllCheckbox = document.getElementById('selectAllCheckbox');
|
||||||
const visibleCards = Array.from(document.querySelectorAll('.s2-api-card')).filter(card => card.style.display !== 'none');
|
if (!selectAllCheckbox) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (visibleCards.length === 0) {
|
if (filteredApis.length === 0) {
|
||||||
selectAllCheckbox.checked = false;
|
selectAllCheckbox.checked = false;
|
||||||
selectAllCheckbox.indeterminate = false;
|
selectAllCheckbox.indeterminate = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const visibleCheckboxes = visibleCards.map(card => card.querySelector('.s2-api-checkbox'));
|
const checkedCount = filteredApis.filter(api => selectedApis.has(api.apiId)).length;
|
||||||
const checkedCount = visibleCheckboxes.filter(cb => cb.checked).length;
|
|
||||||
|
|
||||||
if (checkedCount === 0) {
|
if (checkedCount === 0) {
|
||||||
selectAllCheckbox.checked = false;
|
selectAllCheckbox.checked = false;
|
||||||
selectAllCheckbox.indeterminate = false;
|
selectAllCheckbox.indeterminate = false;
|
||||||
} else if (checkedCount === visibleCheckboxes.length) {
|
} else if (checkedCount === filteredApis.length) {
|
||||||
selectAllCheckbox.checked = true;
|
selectAllCheckbox.checked = true;
|
||||||
selectAllCheckbox.indeterminate = false;
|
selectAllCheckbox.indeterminate = false;
|
||||||
} else {
|
} else {
|
||||||
@@ -233,7 +270,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update modal selected APIs list — selectedApis Set 기준 (패치 2)
|
// Update modal selected APIs list — selectedApis Set 기준(패치 2), 이름은 allApis 우선 조회(패치 4)
|
||||||
function updateModalList() {
|
function updateModalList() {
|
||||||
const modalSelectedList = document.getElementById('modalSelectedList');
|
const modalSelectedList = document.getElementById('modalSelectedList');
|
||||||
modalSelectedList.innerHTML = '';
|
modalSelectedList.innerHTML = '';
|
||||||
@@ -244,8 +281,10 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
selectedApis.forEach(function(apiId) {
|
selectedApis.forEach(function(apiId) {
|
||||||
|
const apiData = allApis.find(a => a.apiId === apiId);
|
||||||
const card = document.querySelector('.s2-api-card[data-api-id="' + apiId + '"]');
|
const card = document.querySelector('.s2-api-card[data-api-id="' + apiId + '"]');
|
||||||
const apiName = card ? card.querySelector('.s2-api-card-title').textContent : apiId;
|
const apiName = apiData ? apiData.apiName
|
||||||
|
: (card ? card.querySelector('.s2-api-card-title').textContent : apiId);
|
||||||
|
|
||||||
const apiPill = document.createElement('div');
|
const apiPill = document.createElement('div');
|
||||||
apiPill.className = 's2-api-pill';
|
apiPill.className = 's2-api-pill';
|
||||||
@@ -270,28 +309,69 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search functionality
|
// Pagination 컨트롤 렌더 — fragment/pagination.html 과 동일 마크업/클래스 재사용(전역 _pagination.scss 적용)
|
||||||
|
function renderPagination(totalItems) {
|
||||||
|
if (!paginationEl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
paginationEl.innerHTML = '';
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(totalItems / PAGE_SIZE);
|
||||||
|
if (totalPages <= 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ICON_FIRST = '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M0 19V5H2.30769V19H0ZM15 19L4.61538 12L15 5V19Z" fill="currentColor"/><path d="M24 19L15 12L24 5V19Z" fill="currentColor"/></svg><span class="blind">처음 페이지</span>';
|
||||||
|
const ICON_PREV = '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M16 5L16 19L5 12L16 5Z" fill="currentColor"/></svg><span class="blind">이전 페이지</span>';
|
||||||
|
const ICON_NEXT = '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M8 19V5L19 12L8 19Z" fill="currentColor"/></svg><span class="blind">다음 페이지</span>';
|
||||||
|
const ICON_LAST = '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M24 5L24 19L21.6923 19L21.6923 5L24 5ZM9 5L19.3846 12L9 19L9 5Z" fill="currentColor"/><path d="M1.22392e-06 5L9 12L0 19L1.22392e-06 5Z" fill="currentColor"/></svg><span class="blind">마지막 페이지</span>';
|
||||||
|
|
||||||
|
function navLink(cls, iconHtml, targetPage, disabled) {
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = '#';
|
||||||
|
a.className = cls + (disabled ? ' disabled' : '');
|
||||||
|
a.innerHTML = iconHtml;
|
||||||
|
if (!disabled) {
|
||||||
|
a.addEventListener('click', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
goToPage(targetPage);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
|
||||||
|
function numLink(p) {
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = '#';
|
||||||
|
const isCurrent = p === currentPage;
|
||||||
|
a.className = 'page-num' + (isCurrent ? ' page-current' : '');
|
||||||
|
a.textContent = String(p);
|
||||||
|
if (!isCurrent) {
|
||||||
|
a.addEventListener('click', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
goToPage(p);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
|
||||||
|
paginationEl.appendChild(navLink('page-first', ICON_FIRST, 1, currentPage === 1));
|
||||||
|
paginationEl.appendChild(navLink('page-prev', ICON_PREV, currentPage - 1, currentPage === 1));
|
||||||
|
|
||||||
|
const windowStart = Math.max(1, Math.min(currentPage - 2, totalPages - 4));
|
||||||
|
const windowEnd = Math.min(totalPages, windowStart + 4);
|
||||||
|
for (let p = Math.max(1, windowStart); p <= windowEnd; p++) {
|
||||||
|
paginationEl.appendChild(numLink(p));
|
||||||
|
}
|
||||||
|
|
||||||
|
paginationEl.appendChild(navLink('page-next', ICON_NEXT, currentPage + 1, currentPage === totalPages));
|
||||||
|
paginationEl.appendChild(navLink('page-last', ICON_LAST, totalPages, currentPage === totalPages));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search functionality — 클라이언트 필터(재조회 없음), 필터 변경 시 1페이지로 리셋
|
||||||
if (searchInput) {
|
if (searchInput) {
|
||||||
searchInput.addEventListener('input', function() {
|
searchInput.addEventListener('input', function() {
|
||||||
const searchTerm = this.value.toLowerCase();
|
applySearch();
|
||||||
|
|
||||||
const apiCards = document.querySelectorAll('.s2-api-card');
|
|
||||||
let visibleCount = 0;
|
|
||||||
apiCards.forEach(function(card) {
|
|
||||||
const apiName = card.getAttribute('data-name');
|
|
||||||
const apiDesc = card.getAttribute('data-desc');
|
|
||||||
const matchesSearch = apiName.includes(searchTerm) || apiDesc.includes(searchTerm);
|
|
||||||
|
|
||||||
if (matchesSearch) {
|
|
||||||
card.style.display = '';
|
|
||||||
visibleCount++;
|
|
||||||
} else {
|
|
||||||
card.style.display = 'none';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('apiResultCount').textContent = visibleCount;
|
|
||||||
updateSelectAllCheckboxState();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -307,11 +387,10 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
currentFilter = groupId;
|
currentFilter = groupId;
|
||||||
currentServiceName = this.textContent.trim();
|
currentServiceName = this.textContent.trim();
|
||||||
|
|
||||||
loadApis(groupId);
|
|
||||||
|
|
||||||
if (searchInput) {
|
if (searchInput) {
|
||||||
searchInput.value = '';
|
searchInput.value = '';
|
||||||
}
|
}
|
||||||
|
loadApis(groupId);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -398,18 +477,26 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Select All checkbox event
|
// Select All checkbox event — 현재 페이지가 아닌 필터된 전체 목록 대상(페이징 무관)
|
||||||
const selectAllCheckbox = document.getElementById('selectAllCheckbox');
|
const selectAllCheckbox = document.getElementById('selectAllCheckbox');
|
||||||
if (selectAllCheckbox) {
|
if (selectAllCheckbox) {
|
||||||
selectAllCheckbox.addEventListener('change', function() {
|
selectAllCheckbox.addEventListener('change', function() {
|
||||||
const isChecked = this.checked;
|
const isChecked = this.checked;
|
||||||
const visibleCards = Array.from(document.querySelectorAll('.s2-api-card')).filter(card => card.style.display !== 'none');
|
|
||||||
|
|
||||||
visibleCards.forEach(function(card) {
|
filteredApis.forEach(function(api) {
|
||||||
|
if (isChecked) {
|
||||||
|
selectedApis.add(api.apiId);
|
||||||
|
} else {
|
||||||
|
selectedApis.delete(api.apiId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 현재 페이지에 실제 렌더된 카드만 체크 상태 동기화
|
||||||
|
document.querySelectorAll('.s2-api-card').forEach(function(card) {
|
||||||
const checkbox = card.querySelector('.s2-api-checkbox');
|
const checkbox = card.querySelector('.s2-api-checkbox');
|
||||||
if (checkbox) {
|
if (checkbox) {
|
||||||
checkbox.checked = isChecked;
|
checkbox.checked = isChecked;
|
||||||
updateCardSelection(checkbox);
|
card.classList.toggle('selected', isChecked);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -756,7 +756,7 @@ $wh-bg-soft: #f9f9f9;
|
|||||||
|
|
||||||
.secret-action-row {
|
.secret-action-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
@@ -767,23 +767,23 @@ $wh-bg-soft: #f9f9f9;
|
|||||||
|
|
||||||
.secret-box {
|
.secret-box {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
max-width: 250px;
|
min-width: 0;
|
||||||
height: 48px;
|
min-height: 48px;
|
||||||
background-color: #efefef;
|
background-color: #efefef;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
padding: 0 20px;
|
padding: 12px 20px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
font-size: 13px;
|
||||||
font-size: 14px;
|
line-height: 1.4;
|
||||||
color: #4e5968;
|
color: #4e5968;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
letter-spacing: 1px;
|
word-break: break-all;
|
||||||
|
white-space: normal;
|
||||||
border: 1px solid #DFDFDF;
|
border: 1px solid #DFDFDF;
|
||||||
|
|
||||||
@media (max-width: 576px) {
|
@media (max-width: 576px) {
|
||||||
max-width: 100%;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
flex: none;
|
flex: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -210,10 +210,17 @@
|
|||||||
<td><code>Content-Type</code></td>
|
<td><code>Content-Type</code></td>
|
||||||
<td>application/json</td>
|
<td>application/json</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>X-Webhook-Secret</code></td>
|
||||||
|
<td>Webhook 관리 화면에서 직접 설정한 값 — <strong>설정한 경우에만</strong> 전송(선택)</td>
|
||||||
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<p class="oauth2-2legged__warning">⚠ 서명 대상은 파싱 전 <strong>본문 원문(raw body)</strong> 입니다.
|
<p class="oauth2-2legged__warning">⚠ 서명 대상은 파싱 전 <strong>본문 원문(raw body)</strong> 입니다.
|
||||||
</p>
|
</p>
|
||||||
|
<p class="oauth2-2legged__warning">⚠ <code>X-Webhook-Secret</code>은 서명 검증과 무관합니다.
|
||||||
|
Webhook 신청/수정 시 입력한 값을 그대로 echo 하는 헤더로, 수신측에서 추가로 값을 대조하고 싶을 때만
|
||||||
|
사용하세요(값을 설정하지 않았다면 이 헤더는 아예 오지 않습니다).</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="oauth2-2legged__code-panel">
|
<div class="oauth2-2legged__code-panel">
|
||||||
@@ -222,10 +229,11 @@
|
|||||||
<span class="o2leg-c">"eventType"</span>: <span class="o2leg-y">"CHECK_START"</span>,
|
<span class="o2leg-c">"eventType"</span>: <span class="o2leg-y">"CHECK_START"</span>,
|
||||||
<span class="o2leg-c">"eventId"</span>: <span class="o2leg-y">"f47ac10b-58cc-4372-a567-0e02b2c3d479"</span>,
|
<span class="o2leg-c">"eventId"</span>: <span class="o2leg-y">"f47ac10b-58cc-4372-a567-0e02b2c3d479"</span>,
|
||||||
<span class="o2leg-c">"timestamp"</span>: <span class="o2leg-p">1723600000000</span>,
|
<span class="o2leg-c">"timestamp"</span>: <span class="o2leg-p">1723600000000</span>,
|
||||||
|
<span class="o2leg-c">"message"</span>: <span class="o2leg-y">"DB 점검으로 15분간 서비스가 중단됩니다."</span>,
|
||||||
<span class="o2leg-c">"data"</span>: [ <span class="o2leg-y">"TESTCASE003S1"</span>, <span class="o2leg-y">"TESTCASE005S1"</span> ]
|
<span class="o2leg-c">"data"</span>: [ <span class="o2leg-y">"TESTCASE003S1"</span>, <span class="o2leg-y">"TESTCASE005S1"</span> ]
|
||||||
}
|
}
|
||||||
|
|
||||||
<span class="o2leg-g"># eventId: 발송 건 고유 ID · data: 영향 API 목록</span></pre>
|
<span class="o2leg-g"># eventId: 발송 건 고유 ID · message: 관리자가 입력한 안내 문구(자유텍스트, 생략될 수 있음) · data: 영향 API 목록</span></pre>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -76,9 +76,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Secret Key -->
|
<!-- 서명검증키 -->
|
||||||
<div class="webhook-info-group">
|
<div class="webhook-info-group">
|
||||||
<span class="group-label">Secret Key</span>
|
<span class="group-label">서명검증키</span>
|
||||||
<div class="secret-action-row">
|
<div class="secret-action-row">
|
||||||
<div class="secret-box" id="secretMasked" th:text="*{secretMasked}">************</div>
|
<div class="secret-box" id="secretMasked" th:text="*{secretMasked}">************</div>
|
||||||
<th:block sec:authorize="hasRole('ROLE_WEBHOOK')">
|
<th:block sec:authorize="hasRole('ROLE_WEBHOOK')">
|
||||||
@@ -88,6 +88,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 사용자 지정 Secret -->
|
||||||
|
<div class="webhook-info-group">
|
||||||
|
<span class="group-label">사용자 지정 Secret</span>
|
||||||
|
<div class="secret-action-row" th:if="*{userSecretMasked != null and !#strings.isEmpty(userSecretMasked)}">
|
||||||
|
<div class="secret-box" id="userSecretMasked" th:text="*{userSecretMasked}">************</div>
|
||||||
|
<th:block sec:authorize="hasRole('ROLE_WEBHOOK')">
|
||||||
|
<button type="button" class="btn-reveal-action" id="btnRevealUserSecret">조회</button>
|
||||||
|
</th:block>
|
||||||
|
</div>
|
||||||
|
<div class="input-display-box" th:unless="*{userSecretMasked != null and !#strings.isEmpty(userSecretMasked)}">미설정</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -163,11 +175,23 @@
|
|||||||
|
|
||||||
function showError(msg) { errorEl.textContent = msg; errorEl.style.display = 'block'; }
|
function showError(msg) { errorEl.textContent = msg; errorEl.style.display = 'block'; }
|
||||||
|
|
||||||
// Secret 조회
|
// 비밀번호 5회 오답 → 서버가 세션을 강제 종료함. 경고 후 로그인 화면으로 이동.
|
||||||
|
// true 반환 시 호출측은 이후 처리를 중단해야 한다.
|
||||||
|
function handleForceLogout(res) {
|
||||||
|
if (res && res.forceLogout) {
|
||||||
|
alert(res.message || '비밀번호 확인 5회 실패로 로그아웃되었습니다.');
|
||||||
|
window.location.href = '/login?pwFailExceeded=1';
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 서명검증키 조회
|
||||||
var btnReveal = document.getElementById('btnRevealSecret');
|
var btnReveal = document.getElementById('btnRevealSecret');
|
||||||
if (btnReveal) btnReveal.addEventListener('click', function () {
|
if (btnReveal) btnReveal.addEventListener('click', function () {
|
||||||
openModal('Secret 조회', '비밀번호 확인 후 Secret Key를 표시합니다.', function (pw) {
|
openModal('서명검증키 조회', '비밀번호 확인 후 서명검증키를 표시합니다.', function (pw) {
|
||||||
post('/webhook/verify-secret', pw).then(function (res) {
|
post('/webhook/verify-secret', pw).then(function (res) {
|
||||||
|
if (handleForceLogout(res)) { return; }
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
document.getElementById('secretMasked').textContent = res.secret;
|
document.getElementById('secretMasked').textContent = res.secret;
|
||||||
closeModal();
|
closeModal();
|
||||||
@@ -176,27 +200,43 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Secret 재발급
|
// 서명검증키 재발급
|
||||||
var btnRegen = document.getElementById('btnRegenSecret');
|
var btnRegen = document.getElementById('btnRegenSecret');
|
||||||
if (btnRegen) btnRegen.addEventListener('click', function () {
|
if (btnRegen) btnRegen.addEventListener('click', function () {
|
||||||
openModal('Secret 재발급',
|
openModal('서명검증키 재발급',
|
||||||
'재발급 시 기존 Secret은 즉시 무효화됩니다. 새 Secret을 수신 서버의 Webhook 서명검증에 반영하지 않으면 이후 발송되는 모든 Webhook의 서명 검증이 실패합니다. 계속하려면 비밀번호를 입력하세요.',
|
'재발급 시 기존 서명검증키는 즉시 무효화됩니다. 새 서명검증키를 수신 서버의 Webhook 서명검증에 반영하지 않으면 이후 발송되는 모든 Webhook의 서명 검증이 실패합니다. 계속하려면 비밀번호를 입력하세요.',
|
||||||
function (pw) {
|
function (pw) {
|
||||||
post('/webhook/regenerate-secret', pw).then(function (res) {
|
post('/webhook/regenerate-secret', pw).then(function (res) {
|
||||||
|
if (handleForceLogout(res)) { return; }
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
document.getElementById('secretMasked').textContent = res.secret;
|
document.getElementById('secretMasked').textContent = res.secret;
|
||||||
closeModal();
|
closeModal();
|
||||||
alert('Secret이 재발급되었습니다.\n반드시 수신 서버의 서명검증 Secret을 새 값으로 교체하세요.\n교체 전까지 Webhook 서명 검증이 실패합니다.');
|
alert('서명검증키가 재발급되었습니다.\n반드시 수신 서버의 서명검증 키를 새 값으로 교체하세요.\n교체 전까지 Webhook 서명 검증이 실패합니다.');
|
||||||
} else { showError(res.message || '실패했습니다.'); }
|
} else { showError(res.message || '실패했습니다.'); }
|
||||||
}).catch(function () { showError('요청 처리 중 오류가 발생했습니다.'); });
|
}).catch(function () { showError('요청 처리 중 오류가 발생했습니다.'); });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 사용자 지정 Secret 조회
|
||||||
|
var btnRevealUserSecret = document.getElementById('btnRevealUserSecret');
|
||||||
|
if (btnRevealUserSecret) btnRevealUserSecret.addEventListener('click', function () {
|
||||||
|
openModal('사용자 지정 Secret 조회', '비밀번호 확인 후 값을 표시합니다.', function (pw) {
|
||||||
|
post('/webhook/verify-secret', pw).then(function (res) {
|
||||||
|
if (handleForceLogout(res)) { return; }
|
||||||
|
if (res.success) {
|
||||||
|
document.getElementById('userSecretMasked').textContent = res.userSecret || '';
|
||||||
|
closeModal();
|
||||||
|
} else { showError(res.message || '실패했습니다.'); }
|
||||||
|
}).catch(function () { showError('요청 처리 중 오류가 발생했습니다.'); });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// 삭제
|
// 삭제
|
||||||
var btnDelete = document.getElementById('btnDeleteWebhook');
|
var btnDelete = document.getElementById('btnDeleteWebhook');
|
||||||
if (btnDelete) btnDelete.addEventListener('click', function () {
|
if (btnDelete) btnDelete.addEventListener('click', function () {
|
||||||
openModal('Webhook 삭제', '삭제하면 복구할 수 없습니다. 계속하려면 비밀번호를 입력하세요.', function (pw) {
|
openModal('Webhook 삭제', '삭제하면 복구할 수 없습니다. 계속하려면 비밀번호를 입력하세요.', function (pw) {
|
||||||
post('/webhook/delete', pw).then(function (res) {
|
post('/webhook/delete', pw).then(function (res) {
|
||||||
|
if (handleForceLogout(res)) { return; }
|
||||||
if (res.success) { window.location.href = '/webhook'; }
|
if (res.success) { window.location.href = '/webhook'; }
|
||||||
else { showError(res.message || '실패했습니다.'); }
|
else { showError(res.message || '실패했습니다.'); }
|
||||||
}).catch(function () { showError('요청 처리 중 오류가 발생했습니다.'); });
|
}).catch(function () { showError('요청 처리 중 오류가 발생했습니다.'); });
|
||||||
|
|||||||
@@ -106,6 +106,17 @@
|
|||||||
<p class="field-help-red">이벤트 발생 시 이 URL로 서명된 POST 요청이 전송됩니다.</p>
|
<p class="field-help-red">이벤트 발생 시 이 URL로 서명된 POST 요청이 전송됩니다.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 사용자 지정 Secret -->
|
||||||
|
<div class="s1-field">
|
||||||
|
<label class="s1-label">사용자 지정 Secret (선택)</label>
|
||||||
|
<input type="text" id="userSecret" th:field="*{userSecret}" class="s1-input"
|
||||||
|
placeholder="발송 요청 헤더에 그대로 포함될 값" autocomplete="off" maxlength="500"
|
||||||
|
pattern="[\x20-\x7E]*" title="ASCII 문자만 입력 가능합니다(한글 등 유니코드 불가).">
|
||||||
|
<p class="field-error" th:if="${#fields.hasErrors('userSecret')}" th:errors="*{userSecret}">오류</p>
|
||||||
|
<p class="field-help-red" th:if="${userSecretSet}">현재 값이 설정되어 있습니다. 공란으로 두면 기존 값이 유지되고, 값을 입력하면 교체됩니다. (ASCII 문자만 가능, 한글 등 유니코드 불가)</p>
|
||||||
|
<p class="field-help" th:unless="${userSecretSet}">입력한 값은 Webhook 발송 시 요청 헤더에 그대로 포함되어 전달됩니다. 수신측 값 검증 용도로 사용하세요. (영문·숫자·특수문자 등 ASCII 문자만 가능, 한글 등 유니코드 불가)</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 알림 이벤트 -->
|
<!-- 알림 이벤트 -->
|
||||||
<div class="s1-field">
|
<div class="s1-field">
|
||||||
<label class="s1-label">알림 받을 이벤트 <span class="s1-required">*</span></label>
|
<label class="s1-label">알림 받을 이벤트 <span class="s1-required">*</span></label>
|
||||||
|
|||||||
@@ -153,7 +153,7 @@
|
|||||||
<div class="s3-message-wrapper">
|
<div class="s3-message-wrapper">
|
||||||
<h1 class="s3-success-title">Webhook 수정이 완료되었습니다.</h1>
|
<h1 class="s3-success-title">Webhook 수정이 완료되었습니다.</h1>
|
||||||
<p class="s3-success-desc">
|
<p class="s3-success-desc">
|
||||||
Secret Key는 변경되지 않았습니다.
|
서명검증키는 변경되지 않았습니다.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -80,6 +80,16 @@
|
|||||||
<p class="field-help">이벤트 발생 시 이 URL로 서명된 POST 요청이 전송됩니다.</p>
|
<p class="field-help">이벤트 발생 시 이 URL로 서명된 POST 요청이 전송됩니다.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 사용자 지정 Secret -->
|
||||||
|
<div class="s1-field">
|
||||||
|
<label class="s1-label">사용자 지정 Secret (선택)</label>
|
||||||
|
<input type="text" id="userSecret" th:field="*{userSecret}" class="s1-input"
|
||||||
|
placeholder="발송 요청 헤더에 그대로 포함될 값" autocomplete="off" maxlength="500"
|
||||||
|
pattern="[\x20-\x7E]*" title="ASCII 문자만 입력 가능합니다(한글 등 유니코드 불가).">
|
||||||
|
<p class="field-error" th:if="${#fields.hasErrors('userSecret')}" th:errors="*{userSecret}">오류</p>
|
||||||
|
<p class="field-help">입력한 값은 Webhook 발송 시 요청 헤더에 그대로 포함되어 전달됩니다. 수신측 값 검증 용도로 사용하세요. (영문·숫자·특수문자 등 ASCII 문자만 가능, 한글 등 유니코드 불가)</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 알림 이벤트 -->
|
<!-- 알림 이벤트 -->
|
||||||
<div class="s1-field">
|
<div class="s1-field">
|
||||||
<label class="s1-label">알림 받을 이벤트 <span class="s1-required">*</span></label>
|
<label class="s1-label">알림 받을 이벤트 <span class="s1-required">*</span></label>
|
||||||
|
|||||||
@@ -64,7 +64,7 @@
|
|||||||
<div class="webhook-complete">
|
<div class="webhook-complete">
|
||||||
<div class="complete-icon">✅</div>
|
<div class="complete-icon">✅</div>
|
||||||
<h3>Webhook이 등록되었습니다</h3>
|
<h3>Webhook이 등록되었습니다</h3>
|
||||||
<p class="field-help">Secret Key는 [Webhook 관리]에서 비밀번호 확인 후 조회할 수 있습니다.</p>
|
<p class="field-help">서명검증키는 [Webhook 관리]에서 비밀번호 확인 후 조회할 수 있습니다.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -23,8 +23,10 @@
|
|||||||
- 폼 id 고정 "apiSelectorForm" — 제출 버튼은 form="apiSelectorForm" 으로 연결.
|
- 폼 id 고정 "apiSelectorForm" — 제출 버튼은 form="apiSelectorForm" 으로 연결.
|
||||||
- "이전" 버튼은 호출 페이지에 id="btnPrevStep" — 모듈 JS가 data-save-action 경로로 저장 POST 후 step1 복귀.
|
- "이전" 버튼은 호출 페이지에 id="btnPrevStep" — 모듈 JS가 data-save-action 경로로 저장 POST 후 step1 복귀.
|
||||||
- 추가 hidden 필드는 호출 페이지에서 form="apiSelectorForm" 속성으로 주입(예: apikey 수정 clientId).
|
- 추가 hidden 필드는 호출 페이지에서 form="apiSelectorForm" 속성으로 주입(예: apikey 수정 clientId).
|
||||||
- API 목록: GET /apis/for_request (ROLE_API_KEY_REQUEST) AJAX.
|
- API 목록: GET /apis/for_request (ROLE_API_KEY_REQUEST) AJAX. 카테고리/검색 전환 시 재조회 없이
|
||||||
- 스타일: design s2-* (_apikey-register.scss step2 재작업분) 재사용.
|
클라이언트에서 12건/페이지로 페이징(#apiPagination, api-selector.js PAGE_SIZE) — 전체선택/모달은 페이징과
|
||||||
|
무관하게 필터된 전체 목록 기준으로 동작.
|
||||||
|
- 스타일: design s2-* (_apikey-register.scss step2 재작업분) + 전역 .pagination(_pagination.scss) 재사용.
|
||||||
*/-->
|
*/-->
|
||||||
<th:block th:fragment="apiSelector(apiServices, selectedApis, formAction, saveAction)">
|
<th:block th:fragment="apiSelector(apiServices, selectedApis, formAction, saveAction)">
|
||||||
|
|
||||||
@@ -94,6 +96,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination (JS 렌더 — fragment/pagination.html 과 동일 마크업/클래스, 전역 CSS 재사용) -->
|
||||||
|
<div class="pagination" id="apiPagination"></div>
|
||||||
|
|
||||||
</form>
|
</form>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user