모니터링 코드 엔티티 제거 및 API 선택 모듈 추가:
- MonitoringCode 엔티티, 리포지토리, 복합키 클래스 삭제 - API 선택 공용 모듈 JS 및 HTML 파일 추가 (api_selector) - 웹훅 개발 가이드 페이지 신규 추가 (webhook-dev-guide)
This commit is contained in:
+26
-9
@@ -5,7 +5,6 @@ import com.eactive.apim.portal.apps.app.service.AppServiceFacade;
|
||||
import com.eactive.apim.portal.apps.apiservice.service.ApiServiceService;
|
||||
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||
import com.eactive.apim.portal.djb.webhook.dto.WebhookCreatedResult;
|
||||
import com.eactive.apim.portal.djb.webhook.dto.WebhookDTO;
|
||||
import com.eactive.apim.portal.djb.webhook.dto.WebhookRegistrationDTO;
|
||||
import com.eactive.apim.portal.djb.webhook.service.WebhookEventTypeProvider;
|
||||
@@ -41,10 +40,12 @@ import org.springframework.web.servlet.mvc.support.RedirectAttributes;
|
||||
@Slf4j
|
||||
@Controller
|
||||
@RequestMapping("/webhook")
|
||||
@Secured("ROLE_APP")
|
||||
@Secured("ROLE_API_KEY_REQUEST")
|
||||
@RequiredArgsConstructor
|
||||
@SessionAttributes({"webhookRegistration", "webhookModification"})
|
||||
public class WebhookController {
|
||||
// 클래스 기본: 법인 관리자(ROLE_API_KEY_REQUEST)만 신청/수정/삭제/Secret 관리 가능.
|
||||
// 조회(index)만 메서드 레벨에서 ROLE_APP 으로 완화(같은 기관 일반 사용자도 열람).
|
||||
|
||||
private static final int TOTAL_STEPS = 3;
|
||||
|
||||
@@ -65,6 +66,7 @@ public class WebhookController {
|
||||
|
||||
// ============================ 조회 ============================
|
||||
|
||||
@Secured("ROLE_APP")
|
||||
@GetMapping
|
||||
public ModelAndView index() {
|
||||
String orgId = currentOrgId();
|
||||
@@ -127,12 +129,20 @@ public class WebhookController {
|
||||
return new ModelAndView("redirect:/webhook/register/step1");
|
||||
}
|
||||
ModelAndView mav = new ModelAndView("apps/webhook/webhookRegisterStep2");
|
||||
mav.addObject("apiGroups", apiServiceService.searchApiGroups(new ApiGroupSearch()));
|
||||
mav.addObject("selectedApis", registration.getSelectedApis());
|
||||
mav.addObject("apiServices", apiServiceService.searchApiGroups(new ApiGroupSearch()));
|
||||
addStepModel(mav, 2);
|
||||
return mav;
|
||||
}
|
||||
|
||||
/** Step2 "이전" — 현재 선택을 세션에 저장하고 Step1 로 복귀 (App 신청 saveStep2 답습). */
|
||||
@PostMapping("/register/step2/save")
|
||||
public ModelAndView saveStep2(
|
||||
@RequestParam(value = "selectedApis", required = false) List<String> selectedApis,
|
||||
@ModelAttribute("webhookRegistration") WebhookRegistrationDTO registration) {
|
||||
registration.setSelectedApis(selectedApis != null ? selectedApis : new java.util.ArrayList<>());
|
||||
return new ModelAndView("redirect:/webhook/register/step1");
|
||||
}
|
||||
|
||||
@PostMapping("/register/step2")
|
||||
public ModelAndView processStep2(
|
||||
@RequestParam(value = "selectedApis", required = false) List<String> selectedApis,
|
||||
@@ -147,11 +157,10 @@ public class WebhookController {
|
||||
}
|
||||
|
||||
try {
|
||||
WebhookCreatedResult result = webhookService.create(registration, currentOrgId());
|
||||
// 평문 Secret 은 화면에 노출하지 않는다 — 목록에서 비밀번호 재인증 후 조회.
|
||||
webhookService.create(registration, currentOrgId());
|
||||
sessionStatus.setComplete();
|
||||
redirectAttributes.addFlashAttribute("registrationSuccess", true);
|
||||
redirectAttributes.addFlashAttribute("secret", result.getSecret());
|
||||
redirectAttributes.addFlashAttribute("webhookId", result.getId());
|
||||
return new ModelAndView("redirect:/webhook/register/step3");
|
||||
} catch (RuntimeException e) {
|
||||
log.warn("Webhook 신청 실패 orgId={} : {}", currentOrgId(), e.getMessage());
|
||||
@@ -229,12 +238,20 @@ public class WebhookController {
|
||||
return new ModelAndView("redirect:/webhook/modify/step1");
|
||||
}
|
||||
ModelAndView mav = new ModelAndView("apps/webhook/webhookModifyStep2");
|
||||
mav.addObject("apiGroups", apiServiceService.searchApiGroups(new ApiGroupSearch()));
|
||||
mav.addObject("selectedApis", modification.getSelectedApis());
|
||||
mav.addObject("apiServices", apiServiceService.searchApiGroups(new ApiGroupSearch()));
|
||||
addStepModel(mav, 2);
|
||||
return mav;
|
||||
}
|
||||
|
||||
/** 수정 Step2 "이전" — 현재 선택을 세션에 저장하고 Step1 로 복귀. */
|
||||
@PostMapping("/modify/step2/save")
|
||||
public ModelAndView saveModifyStep2(
|
||||
@RequestParam(value = "selectedApis", required = false) List<String> selectedApis,
|
||||
@ModelAttribute("webhookModification") WebhookRegistrationDTO modification) {
|
||||
modification.setSelectedApis(selectedApis != null ? selectedApis : new java.util.ArrayList<>());
|
||||
return new ModelAndView("redirect:/webhook/modify/step1");
|
||||
}
|
||||
|
||||
@PostMapping("/modify/step2")
|
||||
public ModelAndView processModifyStep2(
|
||||
@RequestParam(value = "selectedApis", required = false) List<String> selectedApis,
|
||||
|
||||
-16
@@ -1,16 +0,0 @@
|
||||
package com.eactive.apim.portal.djb.webhook.repository;
|
||||
|
||||
import com.eactive.apim.portal.djb.webhook.repository.entity.MonitoringCode;
|
||||
import com.eactive.apim.portal.djb.webhook.repository.entity.MonitoringCodeId;
|
||||
import com.eactive.eai.rms.data.EMSDataSource;
|
||||
import java.util.List;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
/**
|
||||
* TSEAIRM28 모니터링 공통코드 조회 — Webhook EventType 목록(CODEGROUP='EVENT_TYPE') 전용.
|
||||
*/
|
||||
@EMSDataSource
|
||||
public interface MonitoringCodeRepository extends JpaRepository<MonitoringCode, MonitoringCodeId> {
|
||||
|
||||
List<MonitoringCode> findByCodeGroupAndUseYnOrderBySeqAscCodeAsc(String codeGroup, String useYn);
|
||||
}
|
||||
-43
@@ -1,43 +0,0 @@
|
||||
package com.eactive.apim.portal.djb.webhook.repository.entity;
|
||||
|
||||
import java.io.Serializable;
|
||||
import javax.persistence.Column;
|
||||
import javax.persistence.Entity;
|
||||
import javax.persistence.Id;
|
||||
import javax.persistence.IdClass;
|
||||
import javax.persistence.Table;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* 모니터링 공통코드 (EMSAPP.TSEAIRM28) — 읽기 전용.
|
||||
*
|
||||
* Webhook EventType 목록은 CODEGROUP='EVENT_TYPE' 행에서 조회한다(admin 발송엔진과 단일 소스).
|
||||
* 코드/한글명 매핑만 필요하므로 최소 컬럼만 매핑한다.
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@Entity
|
||||
@Table(name = "TSEAIRM28")
|
||||
@IdClass(MonitoringCodeId.class)
|
||||
public class MonitoringCode implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Id
|
||||
@Column(name = "CODEGROUP", length = 50)
|
||||
private String codeGroup;
|
||||
|
||||
@Id
|
||||
@Column(name = "CODE", length = 50)
|
||||
private String code;
|
||||
|
||||
@Column(name = "CODENAME", length = 150)
|
||||
private String codeName;
|
||||
|
||||
@Column(name = "SEQ")
|
||||
private Long seq;
|
||||
|
||||
@Column(name = "USEYN", length = 1)
|
||||
private String useYn;
|
||||
}
|
||||
-24
@@ -1,24 +0,0 @@
|
||||
package com.eactive.apim.portal.djb.webhook.repository.entity;
|
||||
|
||||
import java.io.Serializable;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* {@link MonitoringCode} 복합키 (CODEGROUP + CODE).
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@EqualsAndHashCode
|
||||
public class MonitoringCodeId implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private String codeGroup;
|
||||
private String code;
|
||||
}
|
||||
+37
-20
@@ -1,43 +1,49 @@
|
||||
package com.eactive.apim.portal.djb.webhook.service;
|
||||
|
||||
import com.eactive.apim.portal.djb.webhook.dto.WebhookEventTypeDTO;
|
||||
import com.eactive.apim.portal.djb.webhook.repository.MonitoringCodeRepository;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import javax.persistence.EntityManager;
|
||||
import javax.persistence.PersistenceContext;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* EventType 코드/한글명 제공자. TSEAIRM28(CODEGROUP='EVENT_TYPE', USEYN='Y') 를 조회한다.
|
||||
* EventType 코드/한글명 제공자. EMSAPP.TSEAIRM28(CODEGROUP='EVENT_TYPE', USEYN='Y') 를 조회한다.
|
||||
* admin 발송엔진과 동일 공통코드 테이블을 단일 소스로 사용하므로 코드 추가/변경 시 DB 만 수정하면 된다.
|
||||
*
|
||||
* TSEAIRM28 은 elink-portal-common 의 {@code MonitoringCode} 엔티티가 이미 매핑하고 있어(같은 테이블 중복 @Entity 금지),
|
||||
* 여기서는 별도 엔티티 없이 EMS EntityManager 네이티브 쿼리로 필요한 두 컬럼만 조회한다.
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class WebhookEventTypeProvider {
|
||||
|
||||
private static final String CODE_GROUP = "EVENT_TYPE";
|
||||
private static final String USE_Y = "Y";
|
||||
private static final String EVENT_TYPE_SQL =
|
||||
"SELECT CODE, CODENAME FROM TSEAIRM28 "
|
||||
+ "WHERE CODEGROUP = 'EVENT_TYPE' AND USEYN = 'Y' "
|
||||
+ "ORDER BY SEQ, CODE";
|
||||
|
||||
private final MonitoringCodeRepository monitoringCodeRepository;
|
||||
/** EMS 데이터소스가 @Primary 이므로 기본 EntityManager 는 EMS 를 가리킨다. */
|
||||
@PersistenceContext
|
||||
private EntityManager entityManager;
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<WebhookEventTypeDTO> getAll() {
|
||||
return monitoringCodeRepository
|
||||
.findByCodeGroupAndUseYnOrderBySeqAscCodeAsc(CODE_GROUP, USE_Y)
|
||||
.stream()
|
||||
.map(c -> new WebhookEventTypeDTO(c.getCode(), c.getCodeName()))
|
||||
.collect(Collectors.toList());
|
||||
List<WebhookEventTypeDTO> list = new ArrayList<>();
|
||||
for (Object[] row : rows()) {
|
||||
list.add(new WebhookEventTypeDTO(asString(row[0]), asString(row[1])));
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public Map<String, String> asMap() {
|
||||
Map<String, String> map = new LinkedHashMap<>();
|
||||
monitoringCodeRepository
|
||||
.findByCodeGroupAndUseYnOrderBySeqAscCodeAsc(CODE_GROUP, USE_Y)
|
||||
.forEach(c -> map.put(c.getCode(), c.getCodeName()));
|
||||
for (Object[] row : rows()) {
|
||||
map.put(asString(row[0]), asString(row[1]));
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
@@ -46,14 +52,25 @@ public class WebhookEventTypeProvider {
|
||||
if (code == null) {
|
||||
return false;
|
||||
}
|
||||
return monitoringCodeRepository
|
||||
.findByCodeGroupAndUseYnOrderBySeqAscCodeAsc(CODE_GROUP, USE_Y)
|
||||
.stream()
|
||||
.anyMatch(c -> code.equals(c.getCode()));
|
||||
for (Object[] row : rows()) {
|
||||
if (code.equals(asString(row[0]))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public String getName(String code) {
|
||||
return asMap().getOrDefault(code, code);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private List<Object[]> rows() {
|
||||
return entityManager.createNativeQuery(EVENT_TYPE_SQL).getResultList();
|
||||
}
|
||||
|
||||
private String asString(Object value) {
|
||||
return value == null ? null : value.toString();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,6 +203,10 @@ portal:
|
||||
method: GET
|
||||
view-name: apps/service/oauth2-guide
|
||||
|
||||
- path-pattern: /service/webhook-dev-guide
|
||||
method: GET
|
||||
view-name: apps/service/webhook-dev-guide
|
||||
|
||||
- path-pattern: /dashboard
|
||||
method: GET
|
||||
view-name: apps/mypage/dashboard
|
||||
@@ -295,6 +299,9 @@ page:
|
||||
oauth2_guide:
|
||||
name: "OAuth2 개발가이드"
|
||||
path: "/service/oauth2-guide"
|
||||
webhook_dev_guide:
|
||||
name: "웹훅 개발가이드"
|
||||
path: "/service/webhook-dev-guide"
|
||||
apis:
|
||||
name: "API"
|
||||
path: "#"
|
||||
|
||||
@@ -19990,6 +19990,27 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.webhook-guide-link {
|
||||
margin-top: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
.webhook-guide-link a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 10px 18px;
|
||||
border: 1px dashed #e2e6ee;
|
||||
border-radius: 10px;
|
||||
color: #0b5fff;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
.webhook-guide-link a:hover {
|
||||
border-color: #0b5fff;
|
||||
background: rgba(11, 95, 255, 0.05);
|
||||
}
|
||||
|
||||
.webhook-modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
|
||||
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,464 @@
|
||||
/**
|
||||
* API 선택 공용 모듈 (fragment/api_selector.html 전용)
|
||||
*
|
||||
* 사용처: 앱(API Key) 신청/수정 step2, Webhook 신청/수정 step2
|
||||
*
|
||||
* 계약:
|
||||
* - 폼: #apiSelectorForm (data-save-action = "이전" 저장 POST 경로)
|
||||
* - 기선택: window.API_SELECTOR_SELECTED (fragment 인라인 스크립트가 주입)
|
||||
* - "이전" 버튼: 호출 페이지의 #btnPrevStep (없으면 스킵)
|
||||
* - API 목록: GET /apis/for_request[?groupIds=] (ROLE_API_KEY_REQUEST)
|
||||
*
|
||||
* 원본(apiKeyRegisterStep2 인라인 스크립트) 대비 패치 3건:
|
||||
* 1) 모달 열 때마다 updateModalList() 재빌드 — 세션 복원 직후(카드 렌더 전) 빈 모달 방지
|
||||
* 2) 모달 리스트를 DOM 체크박스가 아닌 selectedApis Set 기준으로 생성 — 미렌더/타 카테고리 누락 방지
|
||||
* 3) 제출/이전 시 DOM에 없는 선택분을 hidden input으로 주입 — 카테고리 필터 상태 전송 유실 방지
|
||||
*/
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const form = document.getElementById('apiSelectorForm');
|
||||
if (!form) {
|
||||
return; // 모듈 미사용 페이지
|
||||
}
|
||||
|
||||
// DOM Elements
|
||||
const searchInput = document.getElementById('apiSearch');
|
||||
const sidebar = document.getElementById('apiSidebar');
|
||||
const mobileToggle = document.getElementById('mobileToggle');
|
||||
const mobileOverlay = document.getElementById('mobileOverlay');
|
||||
const menuTitles = document.querySelectorAll('.menu-title');
|
||||
const apiCardGrid = document.getElementById('apiCardGrid');
|
||||
const loadingState = document.getElementById('loadingState');
|
||||
const emptyState = document.getElementById('emptyState');
|
||||
|
||||
let currentFilter = ''; // Empty string means "all"
|
||||
let currentServiceName = '전체';
|
||||
let allApis = [];
|
||||
let selectedApis = new Set();
|
||||
|
||||
// Restore selected APIs from session (fragment 인라인 주입)
|
||||
const sessionSelectedApis = window.API_SELECTOR_SELECTED;
|
||||
if (sessionSelectedApis && Array.isArray(sessionSelectedApis)) {
|
||||
sessionSelectedApis.forEach(function(apiId) {
|
||||
selectedApis.add(apiId);
|
||||
});
|
||||
}
|
||||
|
||||
// Load APIs via AJAX
|
||||
function loadApis(groupId) {
|
||||
loadingState.style.display = 'block';
|
||||
emptyState.style.display = 'none';
|
||||
|
||||
document.querySelectorAll('.api-selection-card').forEach(card => card.remove());
|
||||
|
||||
let url = '/apis/for_request';
|
||||
if (groupId) {
|
||||
url += '?groupIds=' + encodeURIComponent(groupId);
|
||||
}
|
||||
|
||||
fetch(url).then(response => response.json()).then(apis => {
|
||||
allApis = apis;
|
||||
loadingState.style.display = 'none';
|
||||
|
||||
if (apis.length === 0) {
|
||||
emptyState.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
renderApiCards(apis);
|
||||
updateSelectAllUI();
|
||||
}).catch(error => {
|
||||
console.error('Failed to load APIs:', error);
|
||||
loadingState.style.display = 'none';
|
||||
emptyState.querySelector('h3').textContent = 'API 로드 실패';
|
||||
emptyState.querySelector('p').textContent = '다시 시도해주세요.';
|
||||
emptyState.style.display = 'block';
|
||||
});
|
||||
}
|
||||
|
||||
// Render API cards
|
||||
function renderApiCards(apis) {
|
||||
const fragment = document.createDocumentFragment();
|
||||
|
||||
apis.forEach(api => {
|
||||
fragment.appendChild(createApiCard(api));
|
||||
});
|
||||
|
||||
apiCardGrid.appendChild(fragment);
|
||||
attachCardEventListeners();
|
||||
}
|
||||
|
||||
// Create API card element
|
||||
function createApiCard(api) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'api-selection-card';
|
||||
card.setAttribute('data-group', api.apiGroupId || '');
|
||||
card.setAttribute('data-name', (api.apiName || '').toLowerCase());
|
||||
card.setAttribute('data-desc', (api.apiSimpleDescription || '').toLowerCase());
|
||||
card.setAttribute('data-api-id', api.apiId);
|
||||
|
||||
const isSelected = selectedApis.has(api.apiId);
|
||||
if (isSelected) {
|
||||
card.classList.add('selected');
|
||||
}
|
||||
|
||||
const mainIconHtml = api.mainIcon
|
||||
? `<img src="${api.mainIcon}" alt="${api.apiName}" onerror="this.style.display='none'; this.nextElementSibling.style.display='block'"><i class="fas fa-cube" style="display:none"></i>`
|
||||
: `<i class="fas fa-cube"></i>`;
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="api-card-content">
|
||||
<div class="api-card-header">
|
||||
<span class="api-card-category">${api.service || '카테고리'}</span>
|
||||
<div class="checkbox-wrapper">
|
||||
<input type="checkbox"
|
||||
name="selectedApis"
|
||||
value="${api.apiId}"
|
||||
id="api-${api.apiId}"
|
||||
class="api-checkbox visually-hidden"
|
||||
${isSelected ? 'checked' : ''}>
|
||||
<span class="custom-checkbox"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="api-name">${api.apiName || 'API 이름'}</h3>
|
||||
<p class="api-description">${api.apiSimpleDescription || 'API 설명이 없습니다.'}</p>
|
||||
|
||||
<div class="api-card-icon">
|
||||
${mainIconHtml}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
// Attach event listeners to cards
|
||||
function attachCardEventListeners() {
|
||||
const checkboxes = document.querySelectorAll('.api-checkbox');
|
||||
const apiCards = document.querySelectorAll('.api-selection-card');
|
||||
|
||||
checkboxes.forEach(checkbox => {
|
||||
checkbox.addEventListener('change', function() {
|
||||
updateCardSelection(this);
|
||||
updateSelectedCount();
|
||||
});
|
||||
});
|
||||
|
||||
apiCards.forEach(card => {
|
||||
card.addEventListener('click', function(e) {
|
||||
if (e.target.classList.contains('api-checkbox')) {
|
||||
return;
|
||||
}
|
||||
const checkbox = card.querySelector('.api-checkbox');
|
||||
if (checkbox) {
|
||||
checkbox.checked = !checkbox.checked;
|
||||
updateCardSelection(checkbox);
|
||||
updateSelectedCount();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Update card visual state
|
||||
function updateCardSelection(checkbox) {
|
||||
const card = checkbox.closest('.api-selection-card');
|
||||
if (checkbox.checked) {
|
||||
card.classList.add('selected');
|
||||
selectedApis.add(checkbox.value);
|
||||
} else {
|
||||
card.classList.remove('selected');
|
||||
selectedApis.delete(checkbox.value);
|
||||
}
|
||||
}
|
||||
|
||||
// Update selected count
|
||||
function updateSelectedCount() {
|
||||
const floatingCartBtn = document.getElementById('floatingCartBtn');
|
||||
const cartCount = document.querySelector('.cart-count');
|
||||
|
||||
if (selectedApis.size > 0) {
|
||||
floatingCartBtn.style.display = 'flex';
|
||||
cartCount.textContent = selectedApis.size;
|
||||
} else {
|
||||
floatingCartBtn.style.display = 'none';
|
||||
}
|
||||
|
||||
updateModalList();
|
||||
updateSelectAllCheckboxState();
|
||||
}
|
||||
|
||||
// Update select all UI visibility and text
|
||||
function updateSelectAllUI() {
|
||||
const selectAllWrapper = document.getElementById('selectAllWrapper');
|
||||
const selectAllText = document.getElementById('selectAllText');
|
||||
|
||||
if (currentFilter === '') {
|
||||
selectAllWrapper.style.display = 'none';
|
||||
} else {
|
||||
selectAllWrapper.style.display = 'flex';
|
||||
selectAllText.textContent = currentServiceName + ' API 전체 선택';
|
||||
}
|
||||
|
||||
updateSelectAllCheckboxState();
|
||||
}
|
||||
|
||||
// Update select all checkbox state based on visible cards
|
||||
function updateSelectAllCheckboxState() {
|
||||
const selectAllCheckbox = document.getElementById('selectAllCheckbox');
|
||||
const visibleCards = Array.from(document.querySelectorAll('.api-selection-card')).filter(card => card.style.display !== 'none');
|
||||
|
||||
if (visibleCards.length === 0) {
|
||||
selectAllCheckbox.checked = false;
|
||||
selectAllCheckbox.indeterminate = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const visibleCheckboxes = visibleCards.map(card => card.querySelector('.api-checkbox'));
|
||||
const checkedCount = visibleCheckboxes.filter(cb => cb.checked).length;
|
||||
|
||||
if (checkedCount === 0) {
|
||||
selectAllCheckbox.checked = false;
|
||||
selectAllCheckbox.indeterminate = false;
|
||||
} else if (checkedCount === visibleCheckboxes.length) {
|
||||
selectAllCheckbox.checked = true;
|
||||
selectAllCheckbox.indeterminate = false;
|
||||
} else {
|
||||
selectAllCheckbox.checked = false;
|
||||
selectAllCheckbox.indeterminate = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Update modal selected APIs list — selectedApis Set 기준 (패치 2)
|
||||
function updateModalList() {
|
||||
const modalSelectedList = document.getElementById('modalSelectedList');
|
||||
modalSelectedList.innerHTML = '';
|
||||
|
||||
if (selectedApis.size === 0) {
|
||||
modalSelectedList.innerHTML = '<p class="empty-message">선택된 API가 없습니다.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
selectedApis.forEach(function(apiId) {
|
||||
const card = document.querySelector('.api-selection-card[data-api-id="' + apiId + '"]');
|
||||
const apiName = card ? card.querySelector('.api-name').textContent : apiId;
|
||||
|
||||
const apiPill = document.createElement('div');
|
||||
apiPill.className = 'api-pill';
|
||||
apiPill.innerHTML = `
|
||||
<span class="api-pill-name">${apiName}</span>
|
||||
<button type="button" class="api-pill-remove" data-value="${apiId}" aria-label="Remove ${apiName}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
`;
|
||||
modalSelectedList.appendChild(apiPill);
|
||||
});
|
||||
|
||||
document.querySelectorAll('.api-pill-remove').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
const value = this.getAttribute('data-value');
|
||||
selectedApis.delete(value);
|
||||
const checkbox = document.querySelector('.api-checkbox[value="' + value + '"]');
|
||||
if (checkbox) {
|
||||
checkbox.checked = false;
|
||||
updateCardSelection(checkbox);
|
||||
}
|
||||
updateSelectedCount();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Search functionality
|
||||
if (searchInput) {
|
||||
searchInput.addEventListener('input', function() {
|
||||
const searchTerm = this.value.toLowerCase();
|
||||
|
||||
document.querySelectorAll('.api-selection-card').forEach(function(card) {
|
||||
const apiName = card.getAttribute('data-name');
|
||||
const apiDesc = card.getAttribute('data-desc');
|
||||
const matchesSearch = apiName.includes(searchTerm) || apiDesc.includes(searchTerm);
|
||||
card.style.display = matchesSearch ? '' : 'none';
|
||||
});
|
||||
|
||||
updateSelectAllCheckboxState();
|
||||
});
|
||||
}
|
||||
|
||||
// Sidebar category selection
|
||||
menuTitles.forEach(function(title) {
|
||||
title.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
menuTitles.forEach(t => t.classList.remove('active'));
|
||||
this.classList.add('active');
|
||||
|
||||
const groupId = this.getAttribute('data-group');
|
||||
currentFilter = groupId;
|
||||
currentServiceName = this.textContent.trim().split('\n')[0].trim();
|
||||
|
||||
loadApis(groupId);
|
||||
|
||||
if (searchInput) {
|
||||
searchInput.value = '';
|
||||
}
|
||||
|
||||
if (window.innerWidth <= 768 && sidebar.classList.contains('mobile-open')) {
|
||||
closeMobileMenu();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Mobile menu toggle
|
||||
function closeMobileMenu() {
|
||||
sidebar.classList.remove('mobile-open');
|
||||
mobileOverlay.classList.remove('active');
|
||||
mobileToggle.innerHTML = '☰';
|
||||
mobileToggle.setAttribute('aria-label', '메뉴 열기');
|
||||
}
|
||||
|
||||
if (mobileToggle && sidebar && mobileOverlay) {
|
||||
mobileToggle.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
sidebar.classList.toggle('mobile-open');
|
||||
mobileOverlay.classList.toggle('active');
|
||||
|
||||
if (sidebar.classList.contains('mobile-open')) {
|
||||
this.innerHTML = '✕';
|
||||
this.setAttribute('aria-label', '메뉴 닫기');
|
||||
} else {
|
||||
this.innerHTML = '☰';
|
||||
this.setAttribute('aria-label', '메뉴 열기');
|
||||
}
|
||||
});
|
||||
|
||||
mobileOverlay.addEventListener('click', closeMobileMenu);
|
||||
}
|
||||
|
||||
window.addEventListener('resize', function() {
|
||||
if (window.innerWidth > 768 && sidebar.classList.contains('mobile-open')) {
|
||||
closeMobileMenu();
|
||||
}
|
||||
});
|
||||
|
||||
// Modal control
|
||||
const floatingCartBtn = document.getElementById('floatingCartBtn');
|
||||
const selectedApisModal = document.getElementById('selectedApisModal');
|
||||
const modalOverlay = document.getElementById('modalOverlay');
|
||||
const modalCloseBtn = document.getElementById('modalCloseBtn');
|
||||
const modalCancelBtn = document.getElementById('modalCancelBtn');
|
||||
|
||||
function openModal() {
|
||||
updateModalList(); // 열 때마다 최신 선택 상태로 재빌드 (패치 1)
|
||||
selectedApisModal.style.display = 'block';
|
||||
setTimeout(function() {
|
||||
if (modalOverlay) {
|
||||
modalOverlay.classList.add('show');
|
||||
}
|
||||
selectedApisModal.classList.add('show');
|
||||
}, 10);
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
if (modalOverlay) {
|
||||
modalOverlay.classList.remove('show');
|
||||
}
|
||||
selectedApisModal.classList.remove('show');
|
||||
|
||||
setTimeout(function() {
|
||||
selectedApisModal.style.display = 'none';
|
||||
}, 300);
|
||||
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
|
||||
if (floatingCartBtn) {
|
||||
floatingCartBtn.addEventListener('click', openModal);
|
||||
}
|
||||
if (modalOverlay) {
|
||||
modalOverlay.addEventListener('click', closeModal);
|
||||
}
|
||||
if (modalCloseBtn) {
|
||||
modalCloseBtn.addEventListener('click', closeModal);
|
||||
}
|
||||
if (modalCancelBtn) {
|
||||
modalCancelBtn.addEventListener('click', closeModal);
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape' && selectedApisModal.style.display === 'block') {
|
||||
closeModal();
|
||||
}
|
||||
});
|
||||
|
||||
// Select All checkbox event
|
||||
const selectAllCheckbox = document.getElementById('selectAllCheckbox');
|
||||
if (selectAllCheckbox) {
|
||||
selectAllCheckbox.addEventListener('change', function() {
|
||||
const isChecked = this.checked;
|
||||
const visibleCards = Array.from(document.querySelectorAll('.api-selection-card')).filter(card => card.style.display !== 'none');
|
||||
|
||||
visibleCards.forEach(function(card) {
|
||||
const checkbox = card.querySelector('.api-checkbox');
|
||||
if (checkbox) {
|
||||
checkbox.checked = isChecked;
|
||||
updateCardSelection(checkbox);
|
||||
}
|
||||
});
|
||||
|
||||
updateSelectedCount();
|
||||
});
|
||||
}
|
||||
|
||||
// DOM에 렌더되지 않은 선택분을 hidden input으로 주입 — 전송 유실 방지 (패치 3)
|
||||
function syncHiddenSelected() {
|
||||
document.querySelectorAll('input.hidden-selected-api').forEach(el => el.remove());
|
||||
selectedApis.forEach(function(apiId) {
|
||||
if (!document.querySelector('.api-checkbox[value="' + apiId + '"]')) {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'hidden';
|
||||
input.name = 'selectedApis';
|
||||
input.value = apiId;
|
||||
input.className = 'hidden-selected-api';
|
||||
form.appendChild(input);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Previous step button — 선택 저장 후 step1 복귀
|
||||
const btnPrevStep = document.getElementById('btnPrevStep');
|
||||
if (btnPrevStep) {
|
||||
btnPrevStep.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const saveAction = form.getAttribute('data-save-action');
|
||||
if (saveAction) {
|
||||
form.action = saveAction;
|
||||
}
|
||||
syncHiddenSelected();
|
||||
form.submit();
|
||||
});
|
||||
}
|
||||
|
||||
// Form validation
|
||||
form.addEventListener('submit', function(e) {
|
||||
if (selectedApis.size === 0) {
|
||||
e.preventDefault();
|
||||
if (window.customPopups && customPopups.showAlert) {
|
||||
customPopups.showAlert('최소 1개 이상의 API를 선택해주세요.');
|
||||
} else {
|
||||
alert('최소 1개 이상의 API를 선택해주세요.');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
syncHiddenSelected();
|
||||
});
|
||||
|
||||
// Initialize
|
||||
updateSelectedCount();
|
||||
loadApis('');
|
||||
});
|
||||
@@ -243,6 +243,30 @@ $wh-bg-soft: #f5f7fb;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 개발가이드 링크 ----------
|
||||
.webhook-guide-link {
|
||||
margin-top: 20px;
|
||||
text-align: center;
|
||||
|
||||
a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 10px 18px;
|
||||
border: 1px dashed $wh-border;
|
||||
border-radius: 10px;
|
||||
color: $wh-primary;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
border-color: $wh-primary;
|
||||
background: rgba($wh-primary, 0.05);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 비밀번호 모달 ----------
|
||||
.webhook-modal {
|
||||
position: fixed; inset: 0;
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
<!-- Progress Indicator -->
|
||||
<div class="register-progress">
|
||||
<div class="progress-steps">
|
||||
<!-- Step 1: 앱 정보입력 (Completed) -->
|
||||
<!-- Step 1: 앱 정보입력 -->
|
||||
<div class="progress-step-item">
|
||||
<div class="step-icon-wrapper">
|
||||
<div class="step-icon">
|
||||
@@ -64,676 +64,23 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error Message -->
|
||||
<div th:if="${error}" class="alert alert-error">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<line x1="12" y1="8" x2="12" y2="12"></line>
|
||||
<line x1="12" y1="16" x2="12.01" y2="16"></line>
|
||||
</svg>
|
||||
<span th:text="${error}"></span>
|
||||
</div>
|
||||
<!-- API 선택 공용 모듈 -->
|
||||
<th:block th:replace="~{fragment/api_selector :: apiSelector(${apiServices}, ${apiKeyModification.selectedApis}, '/myapikey/modify/step2', '/myapikey/modify/step2/save')}"/>
|
||||
|
||||
<!-- Form Content with Sidebar Layout -->
|
||||
<div class="register-form-container with-sidebar">
|
||||
|
||||
<!-- Sidebar Navigation -->
|
||||
<aside class="apikey-register-sidebar" id="apiSidebar">
|
||||
<nav class="apikey-sidebar-nav">
|
||||
<!-- Sidebar Title -->
|
||||
<div class="sidebar-title">API 서비스 목록</div>
|
||||
|
||||
<!-- All APIs -->
|
||||
<div class="menu-section">
|
||||
<div class="menu-title active" data-group="">
|
||||
<span class="menu-text">전체</span>
|
||||
<span class="api-count"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Service Categories -->
|
||||
<div class="menu-section" th:each="service : ${apiServices}">
|
||||
<div class="menu-title"
|
||||
th:data-group="${service.id}">
|
||||
<span class="menu-text" th:text="${service.groupName}">서비스명</span>
|
||||
<span class="api-count"></span>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<!-- Mobile Menu Toggle -->
|
||||
<button class="apikey-mobile-toggle" id="mobileToggle" aria-label="메뉴 열기">
|
||||
☰
|
||||
</button>
|
||||
|
||||
<!-- Mobile Overlay -->
|
||||
<div class="apikey-mobile-overlay" id="mobileOverlay"></div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="apikey-register-content">
|
||||
<form id="modifyStep2Form" method="post" th:action="@{/myapikey/modify/step2}" th:object="${apiKeyModification}" class="register-form">
|
||||
|
||||
<!-- Hidden field for clientId -->
|
||||
<input type="hidden" th:field="*{clientId}"/>
|
||||
|
||||
<!-- Search Box with Select All -->
|
||||
<div class="api-filter-header">
|
||||
<div class="select-all-wrapper" id="selectAllWrapper" style="display: none;">
|
||||
<label class="select-all-label">
|
||||
<input type="checkbox" id="selectAllCheckbox" class="select-all-checkbox visually-hidden">
|
||||
<span class="custom-checkbox"></span>
|
||||
<span id="selectAllText">전체 선택</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="api-search-box">
|
||||
<input type="text"
|
||||
id="apiSearch"
|
||||
class="search-input"
|
||||
placeholder="API 검색...">
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M9.23047 0C14.3285 0 18.4618 4.0539 18.4619 9.05469C18.4619 11.1083 17.7634 13.0014 16.5889 14.5205C16.5913 14.5229 16.5942 14.5249 16.5967 14.5273L19.5498 17.4238C20.1502 18.0131 20.1501 18.9683 19.5498 19.5576C18.949 20.147 17.9748 20.147 17.374 19.5576L14.4209 16.6621C14.3966 16.6383 14.3749 16.6119 14.3525 16.5869C12.8868 17.5478 11.1257 18.1094 9.23047 18.1094C4.13258 18.1092 0 14.0554 0 9.05469C0.000110268 4.05404 4.13265 0.000222701 9.23047 0ZM9.23047 3.01855C5.83201 3.01878 3.07726 5.721 3.07715 9.05469C3.07715 12.3885 5.83194 15.0916 9.23047 15.0918C12.6292 15.0918 15.3848 12.3886 15.3848 9.05469C15.3847 5.72086 12.6291 3.01855 9.23047 3.01855Z" fill="#515961"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- API Cards Grid -->
|
||||
<div class="api-selection-card-grid" id="apiCardGrid">
|
||||
<!-- Loading state -->
|
||||
<div class="loading-state" id="loadingState" style="grid-column: 1 / -1;">
|
||||
<div class="loading-spinner"></div>
|
||||
<p>API 목록을 불러오는 중...</p>
|
||||
</div>
|
||||
|
||||
<!-- Empty state (initially hidden) -->
|
||||
<div class="empty-api-state" id="emptyState" style="display: none; grid-column: 1 / -1;">
|
||||
<div class="empty-icon">📦</div>
|
||||
<h3>API를 선택해주세요</h3>
|
||||
<p>왼쪽 메뉴에서 서비스를 선택하면 해당 API 목록이 표시됩니다.</p>
|
||||
</div>
|
||||
|
||||
<!-- API cards will be dynamically loaded here -->
|
||||
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</main>
|
||||
|
||||
</div>
|
||||
<!-- clientId 는 모듈 폼에 form 속성으로 주입 -->
|
||||
<input type="hidden" name="clientId" th:value="${apiKeyModification.clientId}" form="apiSelectorForm"/>
|
||||
|
||||
<!-- Form Actions -->
|
||||
<div class="form-actions">
|
||||
<button type="button" id="btnPrevStep" class="btn-submit btn-secondary">
|
||||
이전
|
||||
</button>
|
||||
<button type="submit" form="modifyStep2Form" class="btn-submit btn-primary">
|
||||
<button type="submit" form="apiSelectorForm" class="btn-submit btn-primary">
|
||||
저장
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
<!-- Floating Cart Button -->
|
||||
<button type="button" class="floating-cart-btn" id="floatingCartBtn" style="display: none;">
|
||||
<span class="cart-label">선택된 API</span>
|
||||
<span class="cart-badge" id="cartBadge">
|
||||
<span class="cart-count">0</span>
|
||||
<span class="cart-unit">개</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<!-- Selected APIs Modal - Uses base modal component -->
|
||||
<div class="modal" id="selectedApisModal" style="display: none;">
|
||||
<div class="modal-backdrop" id="modalOverlay"></div>
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title">선택된 API 목록</h3>
|
||||
<button type="button" class="modal-close" id="modalCloseBtn">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="selected-apis-list" id="modalSelectedList">
|
||||
<!-- Dynamically populated -->
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" id="modalCancelBtn">닫기</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</th:block>
|
||||
|
||||
<th:block layout:fragment="contentScript">
|
||||
<script th:inline="javascript">
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// DOM Elements
|
||||
const form = document.getElementById('modifyStep2Form');
|
||||
const searchInput = document.getElementById('apiSearch');
|
||||
const sidebar = document.getElementById('apiSidebar');
|
||||
const mobileToggle = document.getElementById('mobileToggle');
|
||||
const mobileOverlay = document.getElementById('mobileOverlay');
|
||||
const menuTitles = document.querySelectorAll('.menu-title');
|
||||
const apiCardGrid = document.getElementById('apiCardGrid');
|
||||
const loadingState = document.getElementById('loadingState');
|
||||
const emptyState = document.getElementById('emptyState');
|
||||
|
||||
let currentFilter = ''; // Empty string means "all"
|
||||
let currentServiceName = '전체'; // Store current service name
|
||||
let allApis = []; // Store loaded APIs
|
||||
let allLoadedApis = []; // Store ALL APIs from server for modal display
|
||||
let selectedApis = new Set(); // Track selected API IDs
|
||||
|
||||
// Restore selected APIs from session (for modification)
|
||||
const sessionSelectedApis = /*[[${apiKeyModification.selectedApis}]]*/ [];
|
||||
if (sessionSelectedApis && Array.isArray(sessionSelectedApis)) {
|
||||
sessionSelectedApis.forEach(function(apiId) {
|
||||
selectedApis.add(apiId);
|
||||
});
|
||||
}
|
||||
|
||||
// Load all APIs for modal display (used when APIs from different categories are selected)
|
||||
function loadAllApisForModal() {
|
||||
fetch('/apis/for_request')
|
||||
.then(response => response.json())
|
||||
.then(apis => {
|
||||
allLoadedApis = apis;
|
||||
console.log('Loaded', allLoadedApis.length, 'APIs for modal display');
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Failed to load all APIs for modal:', error);
|
||||
});
|
||||
}
|
||||
|
||||
// Load APIs via AJAX
|
||||
function loadApis(groupId) {
|
||||
// Show loading state
|
||||
loadingState.style.display = 'block';
|
||||
emptyState.style.display = 'none';
|
||||
|
||||
// Remove existing API cards
|
||||
document.querySelectorAll('.api-selection-card').forEach(card => card.remove());
|
||||
|
||||
// Build URL with optional groupId filter
|
||||
let url = '/apis/for_request';
|
||||
if (groupId) {
|
||||
url += '?groupIds=' + encodeURIComponent(groupId);
|
||||
}
|
||||
|
||||
fetch(url).then(response => response.json()).then(apis => {
|
||||
allApis = apis;
|
||||
loadingState.style.display = 'none';
|
||||
|
||||
if (apis.length === 0) {
|
||||
emptyState.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
renderApiCards(apis);
|
||||
updateSelectAllUI();
|
||||
}).catch(error => {
|
||||
console.error('Failed to load APIs:', error);
|
||||
loadingState.style.display = 'none';
|
||||
emptyState.querySelector('h3').textContent = 'API 로드 실패';
|
||||
emptyState.querySelector('p').textContent = '다시 시도해주세요.';
|
||||
emptyState.style.display = 'block';
|
||||
});
|
||||
}
|
||||
|
||||
// Render API cards
|
||||
function renderApiCards(apis) {
|
||||
const fragment = document.createDocumentFragment();
|
||||
|
||||
apis.forEach(api => {
|
||||
const card = createApiCard(api);
|
||||
fragment.appendChild(card);
|
||||
});
|
||||
|
||||
apiCardGrid.appendChild(fragment);
|
||||
|
||||
// Re-attach event listeners
|
||||
attachCardEventListeners();
|
||||
|
||||
// Update modal list after cards are rendered
|
||||
updateModalList();
|
||||
}
|
||||
|
||||
// Create API card element
|
||||
function createApiCard(api) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'api-selection-card';
|
||||
card.setAttribute('data-group', api.apiGroupId || '');
|
||||
card.setAttribute('data-name', (api.apiName || '').toLowerCase());
|
||||
card.setAttribute('data-desc', (api.apiSimpleDescription || '').toLowerCase());
|
||||
card.setAttribute('data-api-id', api.apiId);
|
||||
|
||||
const isSelected = selectedApis.has(api.apiId);
|
||||
if (isSelected) {
|
||||
card.classList.add('selected');
|
||||
}
|
||||
|
||||
const mainIconHtml = api.mainIcon
|
||||
? `<img src="${api.mainIcon}" alt="${api.apiName}" onerror="this.style.display='none'; this.nextElementSibling.style.display='block'"><i class="fas fa-cube" style="display:none"></i>`
|
||||
: `<i class="fas fa-cube"></i>`;
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="api-card-content">
|
||||
<div class="api-card-header">
|
||||
<span class="api-card-category">${api.service || '카테고리'}</span>
|
||||
<div class="checkbox-wrapper">
|
||||
<input type="checkbox"
|
||||
name="selectedApis"
|
||||
value="${api.apiId}"
|
||||
id="api-${api.apiId}"
|
||||
class="api-checkbox visually-hidden"
|
||||
${isSelected ? 'checked' : ''}>
|
||||
<span class="custom-checkbox"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="api-name">${api.apiName || 'API 이름'}</h3>
|
||||
<p class="api-description">${api.apiSimpleDescription || 'API 설명이 없습니다.'}</p>
|
||||
|
||||
<div class="api-card-icon">
|
||||
${mainIconHtml}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
// Attach event listeners to cards
|
||||
function attachCardEventListeners() {
|
||||
const checkboxes = document.querySelectorAll('.api-checkbox');
|
||||
const apiCards = document.querySelectorAll('.api-selection-card');
|
||||
|
||||
checkboxes.forEach(checkbox => {
|
||||
checkbox.addEventListener('change', function() {
|
||||
updateCardSelection(this);
|
||||
updateSelectedCount();
|
||||
});
|
||||
});
|
||||
|
||||
apiCards.forEach(card => {
|
||||
card.addEventListener('click', function(e) {
|
||||
// Prevent double toggle if clicking directly on checkbox
|
||||
if (e.target.classList.contains('api-checkbox')) {
|
||||
return;
|
||||
}
|
||||
const checkbox = card.querySelector('.api-checkbox');
|
||||
if (checkbox) {
|
||||
checkbox.checked = !checkbox.checked;
|
||||
updateCardSelection(checkbox);
|
||||
updateSelectedCount();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Update card visual state
|
||||
function updateCardSelection(checkbox) {
|
||||
const card = checkbox.closest('.api-selection-card');
|
||||
if (checkbox.checked) {
|
||||
card.classList.add('selected');
|
||||
selectedApis.add(checkbox.value);
|
||||
} else {
|
||||
card.classList.remove('selected');
|
||||
selectedApis.delete(checkbox.value);
|
||||
}
|
||||
}
|
||||
|
||||
// Update selected count
|
||||
function updateSelectedCount() {
|
||||
// Update floating cart button
|
||||
const floatingCartBtn = document.getElementById('floatingCartBtn');
|
||||
const cartCount = document.querySelector('.cart-count');
|
||||
|
||||
if (selectedApis.size > 0) {
|
||||
floatingCartBtn.style.display = 'flex';
|
||||
cartCount.textContent = selectedApis.size;
|
||||
} else {
|
||||
floatingCartBtn.style.display = 'none';
|
||||
}
|
||||
|
||||
// Update modal list
|
||||
updateModalList();
|
||||
|
||||
// Update select all checkbox state
|
||||
updateSelectAllCheckboxState();
|
||||
}
|
||||
|
||||
// Update select all UI visibility and text
|
||||
function updateSelectAllUI() {
|
||||
const selectAllWrapper = document.getElementById('selectAllWrapper');
|
||||
const selectAllText = document.getElementById('selectAllText');
|
||||
|
||||
if (currentFilter === '') {
|
||||
// Hide select all when "전체" is selected
|
||||
selectAllWrapper.style.display = 'none';
|
||||
} else {
|
||||
// Show select all with service name
|
||||
selectAllWrapper.style.display = 'flex';
|
||||
selectAllText.textContent = currentServiceName + ' API 전체 선택';
|
||||
}
|
||||
|
||||
updateSelectAllCheckboxState();
|
||||
}
|
||||
|
||||
// Update select all checkbox state based on visible cards
|
||||
function updateSelectAllCheckboxState() {
|
||||
const selectAllCheckbox = document.getElementById('selectAllCheckbox');
|
||||
const visibleCards = Array.from(document.querySelectorAll('.api-selection-card')).filter(card => card.style.display !== 'none');
|
||||
|
||||
if (visibleCards.length === 0) {
|
||||
selectAllCheckbox.checked = false;
|
||||
selectAllCheckbox.indeterminate = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const visibleCheckboxes = visibleCards.map(card => card.querySelector('.api-checkbox'));
|
||||
const checkedCount = visibleCheckboxes.filter(cb => cb.checked).length;
|
||||
|
||||
if (checkedCount === 0) {
|
||||
selectAllCheckbox.checked = false;
|
||||
selectAllCheckbox.indeterminate = false;
|
||||
} else if (checkedCount === visibleCheckboxes.length) {
|
||||
selectAllCheckbox.checked = true;
|
||||
selectAllCheckbox.indeterminate = false;
|
||||
} else {
|
||||
selectAllCheckbox.checked = false;
|
||||
selectAllCheckbox.indeterminate = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Update modal selected APIs list
|
||||
function updateModalList() {
|
||||
const modalSelectedList = document.getElementById('modalSelectedList');
|
||||
modalSelectedList.innerHTML = '';
|
||||
|
||||
if (selectedApis.size === 0) {
|
||||
modalSelectedList.innerHTML = '<p class="empty-message">선택된 API가 없습니다.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Build list using selectedApis Set for consistency
|
||||
selectedApis.forEach(function(apiId) {
|
||||
// Try to find the checkbox in the DOM first
|
||||
const checkbox = document.querySelector('.api-checkbox[value="' + apiId + '"]');
|
||||
let apiName = apiId; // Default to ID if we can't find the name
|
||||
|
||||
if (checkbox) {
|
||||
// If checkbox exists in DOM, get the name from the card
|
||||
const card = checkbox.closest('.api-selection-card');
|
||||
if (card) {
|
||||
const nameElement = card.querySelector('.api-name');
|
||||
if (nameElement) {
|
||||
apiName = nameElement.textContent;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// If checkbox not in DOM (different category is showing),
|
||||
// try to find the API in our loaded data
|
||||
let api = allApis.find(a => a.apiId === apiId);
|
||||
if (!api && allLoadedApis.length > 0) {
|
||||
api = allLoadedApis.find(a => a.apiId === apiId);
|
||||
}
|
||||
if (api && api.apiName) {
|
||||
apiName = api.apiName;
|
||||
}
|
||||
}
|
||||
|
||||
const apiPill = document.createElement('div');
|
||||
apiPill.className = 'api-pill';
|
||||
apiPill.innerHTML = `
|
||||
<span class="api-pill-name">${apiName}</span>
|
||||
<button type="button" class="api-pill-remove" data-value="${apiId}" aria-label="Remove ${apiName}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
`;
|
||||
modalSelectedList.appendChild(apiPill);
|
||||
});
|
||||
|
||||
// Add remove handlers
|
||||
document.querySelectorAll('.api-pill-remove').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
const value = this.getAttribute('data-value');
|
||||
const checkbox = document.querySelector('.api-checkbox[value="' + value + '"]');
|
||||
if (checkbox) {
|
||||
checkbox.checked = false;
|
||||
updateCardSelection(checkbox);
|
||||
updateSelectedCount();
|
||||
} else {
|
||||
// If checkbox not found (maybe different category is showing),
|
||||
// still remove from selection
|
||||
selectedApis.delete(value);
|
||||
updateSelectedCount();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Search functionality
|
||||
if (searchInput) {
|
||||
searchInput.addEventListener('input', function() {
|
||||
const searchTerm = this.value.toLowerCase();
|
||||
|
||||
const apiCards = document.querySelectorAll('.api-selection-card');
|
||||
apiCards.forEach(function(card) {
|
||||
const apiName = card.getAttribute('data-name');
|
||||
const apiDesc = card.getAttribute('data-desc');
|
||||
|
||||
// Check if matches search
|
||||
const matchesSearch = apiName.includes(searchTerm) || apiDesc.includes(searchTerm);
|
||||
|
||||
if (matchesSearch) {
|
||||
card.style.display = '';
|
||||
} else {
|
||||
card.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
// Update select all checkbox state after search
|
||||
updateSelectAllCheckboxState();
|
||||
});
|
||||
}
|
||||
|
||||
// Sidebar category selection
|
||||
menuTitles.forEach(function(title) {
|
||||
title.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
// Remove active from all
|
||||
menuTitles.forEach(t => t.classList.remove('active'));
|
||||
// Add active to clicked
|
||||
this.classList.add('active');
|
||||
|
||||
const groupId = this.getAttribute('data-group');
|
||||
currentFilter = groupId;
|
||||
|
||||
// Store service name for select all label
|
||||
currentServiceName = this.textContent.trim().split('\n')[0].trim();
|
||||
|
||||
// Load APIs for selected service
|
||||
loadApis(groupId);
|
||||
|
||||
// Clear search when changing category
|
||||
if (searchInput) {
|
||||
searchInput.value = '';
|
||||
}
|
||||
|
||||
// Close mobile menu if open
|
||||
if (window.innerWidth <= 768 && sidebar.classList.contains('mobile-open')) {
|
||||
closeMobileMenu();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Mobile menu toggle
|
||||
function closeMobileMenu() {
|
||||
sidebar.classList.remove('mobile-open');
|
||||
mobileOverlay.classList.remove('active');
|
||||
mobileToggle.innerHTML = '☰';
|
||||
mobileToggle.setAttribute('aria-label', '메뉴 열기');
|
||||
}
|
||||
|
||||
if (mobileToggle && sidebar && mobileOverlay) {
|
||||
mobileToggle.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
sidebar.classList.toggle('mobile-open');
|
||||
mobileOverlay.classList.toggle('active');
|
||||
|
||||
if (sidebar.classList.contains('mobile-open')) {
|
||||
this.innerHTML = '✕';
|
||||
this.setAttribute('aria-label', '메뉴 닫기');
|
||||
} else {
|
||||
this.innerHTML = '☰';
|
||||
this.setAttribute('aria-label', '메뉴 열기');
|
||||
}
|
||||
});
|
||||
|
||||
// Close menu on overlay click
|
||||
mobileOverlay.addEventListener('click', function() {
|
||||
closeMobileMenu();
|
||||
});
|
||||
}
|
||||
|
||||
// Close mobile menu on resize to desktop
|
||||
window.addEventListener('resize', function() {
|
||||
if (window.innerWidth > 768 && sidebar.classList.contains('mobile-open')) {
|
||||
closeMobileMenu();
|
||||
}
|
||||
});
|
||||
|
||||
// Modal control
|
||||
const floatingCartBtn = document.getElementById('floatingCartBtn');
|
||||
const selectedApisModal = document.getElementById('selectedApisModal');
|
||||
const modalOverlay = document.getElementById('modalOverlay');
|
||||
const modalCloseBtn = document.getElementById('modalCloseBtn');
|
||||
const modalCancelBtn = document.getElementById('modalCancelBtn');
|
||||
|
||||
// Open modal
|
||||
function openModal() {
|
||||
selectedApisModal.style.display = 'block';
|
||||
// Add show class to backdrop and modal for visibility
|
||||
setTimeout(function() {
|
||||
if (modalOverlay) {
|
||||
modalOverlay.classList.add('show');
|
||||
}
|
||||
selectedApisModal.classList.add('show');
|
||||
}, 10);
|
||||
document.body.style.overflow = 'hidden'; // Prevent background scroll
|
||||
}
|
||||
|
||||
// Close modal
|
||||
function closeModal() {
|
||||
// Remove show class first for transition
|
||||
if (modalOverlay) {
|
||||
modalOverlay.classList.remove('show');
|
||||
}
|
||||
selectedApisModal.classList.remove('show');
|
||||
|
||||
// Hide modal after transition
|
||||
setTimeout(function() {
|
||||
selectedApisModal.style.display = 'none';
|
||||
}, 300);
|
||||
|
||||
document.body.style.overflow = ''; // Restore scroll
|
||||
}
|
||||
|
||||
// Floating cart button click
|
||||
if (floatingCartBtn) {
|
||||
floatingCartBtn.addEventListener('click', openModal);
|
||||
}
|
||||
|
||||
// Modal overlay click
|
||||
if (modalOverlay) {
|
||||
modalOverlay.addEventListener('click', closeModal);
|
||||
}
|
||||
|
||||
// Modal close button click
|
||||
if (modalCloseBtn) {
|
||||
modalCloseBtn.addEventListener('click', closeModal);
|
||||
}
|
||||
|
||||
// Modal cancel button click
|
||||
if (modalCancelBtn) {
|
||||
modalCancelBtn.addEventListener('click', closeModal);
|
||||
}
|
||||
|
||||
// Close modal on ESC key
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape' && selectedApisModal.style.display === 'block') {
|
||||
closeModal();
|
||||
}
|
||||
});
|
||||
|
||||
// Select All checkbox event
|
||||
const selectAllCheckbox = document.getElementById('selectAllCheckbox');
|
||||
if (selectAllCheckbox) {
|
||||
selectAllCheckbox.addEventListener('change', function() {
|
||||
const isChecked = this.checked;
|
||||
const visibleCards = Array.from(document.querySelectorAll('.api-selection-card')).filter(card => card.style.display !== 'none');
|
||||
|
||||
visibleCards.forEach(function(card) {
|
||||
const checkbox = card.querySelector('.api-checkbox');
|
||||
if (checkbox) {
|
||||
checkbox.checked = isChecked;
|
||||
updateCardSelection(checkbox);
|
||||
}
|
||||
});
|
||||
|
||||
updateSelectedCount();
|
||||
});
|
||||
}
|
||||
|
||||
// Previous step button - save current selections before going back
|
||||
const btnPrevStep = document.getElementById('btnPrevStep');
|
||||
if (btnPrevStep) {
|
||||
btnPrevStep.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
// Change form action to save endpoint
|
||||
const originalAction = form.action;
|
||||
form.action = '/myapikey/modify/step2/save';
|
||||
|
||||
// Submit the form to save selections
|
||||
form.submit();
|
||||
});
|
||||
}
|
||||
|
||||
// Form validation
|
||||
form.addEventListener('submit', function(e) {
|
||||
// API 선택 필수 검증
|
||||
if (selectedApis.size === 0) {
|
||||
e.preventDefault();
|
||||
customPopups.showAlert('최소 1개 이상의 API를 선택해주세요.');
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize
|
||||
// Load all APIs for modal display FIRST (async)
|
||||
loadAllApisForModal();
|
||||
|
||||
updateSelectedCount(); // This will show count from restored session data
|
||||
|
||||
// Load all APIs automatically on page load (empty string = all APIs)
|
||||
loadApis('');
|
||||
|
||||
// Log restored selections for debugging
|
||||
if (selectedApis.size > 0) {
|
||||
console.log('Restored ' + selectedApis.size + ' selected APIs from session:', Array.from(selectedApis));
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</th:block>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
<!-- Progress Indicator -->
|
||||
<div class="register-progress">
|
||||
<div class="progress-steps">
|
||||
<!-- Step 1: 앱 정보입력 (Active) -->
|
||||
<!-- Step 1: 앱 정보입력 -->
|
||||
<div class="progress-step-item">
|
||||
<div class="step-icon-wrapper">
|
||||
<div class="step-icon">
|
||||
@@ -37,7 +37,7 @@
|
||||
<span></span><span></span><span></span>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: API 선택 -->
|
||||
<!-- Step 2: API 선택 (Active) -->
|
||||
<div class="progress-step-item active">
|
||||
<div class="step-icon-wrapper">
|
||||
<div class="step-icon">
|
||||
@@ -64,625 +64,20 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Form Content with Sidebar Layout -->
|
||||
<div class="register-form-container with-sidebar">
|
||||
|
||||
<!-- Sidebar Navigation -->
|
||||
<aside class="apikey-register-sidebar" id="apiSidebar">
|
||||
<nav class="apikey-sidebar-nav">
|
||||
<!-- Sidebar Title -->
|
||||
<div class="sidebar-title">API 서비스 목록</div>
|
||||
|
||||
<!-- All APIs -->
|
||||
<div class="menu-section">
|
||||
<div class="menu-title active" data-group="">
|
||||
<span class="menu-text">전체</span>
|
||||
<span class="api-count"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Service Categories -->
|
||||
<div class="menu-section" th:each="service : ${apiServices}">
|
||||
<div class="menu-title"
|
||||
th:data-group="${service.id}">
|
||||
<span class="menu-text" th:text="${service.groupName}">서비스명</span>
|
||||
<span class="api-count"></span>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<!-- Mobile Menu Toggle -->
|
||||
<button class="apikey-mobile-toggle" id="mobileToggle" aria-label="메뉴 열기">
|
||||
☰
|
||||
</button>
|
||||
|
||||
<!-- Mobile Overlay -->
|
||||
<div class="apikey-mobile-overlay" id="mobileOverlay"></div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="apikey-register-content">
|
||||
<form id="registerStep2Form" method="post" th:action="@{/myapikey/register/step2}" class="register-form">
|
||||
|
||||
<!-- Search Box with Select All -->
|
||||
<div class="api-filter-header">
|
||||
<div class="select-all-wrapper" id="selectAllWrapper" style="display: none;">
|
||||
<label class="select-all-label">
|
||||
<input type="checkbox" id="selectAllCheckbox" class="select-all-checkbox visually-hidden">
|
||||
<span class="custom-checkbox"></span>
|
||||
<span id="selectAllText">전체 선택</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="api-search-box">
|
||||
<input type="text"
|
||||
id="apiSearch"
|
||||
class="search-input"
|
||||
placeholder="API 검색...">
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M9.23047 0C14.3285 0 18.4618 4.0539 18.4619 9.05469C18.4619 11.1083 17.7634 13.0014 16.5889 14.5205C16.5913 14.5229 16.5942 14.5249 16.5967 14.5273L19.5498 17.4238C20.1502 18.0131 20.1501 18.9683 19.5498 19.5576C18.949 20.147 17.9748 20.147 17.374 19.5576L14.4209 16.6621C14.3966 16.6383 14.3749 16.6119 14.3525 16.5869C12.8868 17.5478 11.1257 18.1094 9.23047 18.1094C4.13258 18.1092 0 14.0554 0 9.05469C0.000110268 4.05404 4.13265 0.000222701 9.23047 0ZM9.23047 3.01855C5.83201 3.01878 3.07726 5.721 3.07715 9.05469C3.07715 12.3885 5.83194 15.0916 9.23047 15.0918C12.6292 15.0918 15.3848 12.3886 15.3848 9.05469C15.3847 5.72086 12.6291 3.01855 9.23047 3.01855Z" fill="#515961"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- API Cards Grid -->
|
||||
<div class="api-selection-card-grid" id="apiCardGrid">
|
||||
<!-- Loading state -->
|
||||
<div class="loading-state" id="loadingState" style="grid-column: 1 / -1;">
|
||||
<div class="loading-spinner"></div>
|
||||
<p>API 목록을 불러오는 중...</p>
|
||||
</div>
|
||||
|
||||
<!-- Empty state (initially hidden) -->
|
||||
<div class="empty-api-state" id="emptyState" style="display: none; grid-column: 1 / -1;">
|
||||
<div class="empty-icon">📦</div>
|
||||
<h3>API를 선택해주세요</h3>
|
||||
<p>왼쪽 메뉴에서 서비스를 선택하면 해당 API 목록이 표시됩니다.</p>
|
||||
</div>
|
||||
|
||||
<!-- API cards will be dynamically loaded here -->
|
||||
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</main>
|
||||
|
||||
</div>
|
||||
<!-- API 선택 공용 모듈 -->
|
||||
<th:block th:replace="~{fragment/api_selector :: apiSelector(${apiServices}, ${apiKeyRegistration.selectedApis}, '/myapikey/register/step2', '/myapikey/register/step2/save')}"/>
|
||||
|
||||
<!-- Form Actions -->
|
||||
<div class="form-actions">
|
||||
<button type="button" id="btnPrevStep" class="btn-submit btn-secondary">
|
||||
이전
|
||||
</button>
|
||||
<button type="submit" form="registerStep2Form" class="btn-submit btn-primary">
|
||||
<button type="submit" form="apiSelectorForm" class="btn-submit btn-primary">
|
||||
저장
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
<!-- Floating Cart Button -->
|
||||
<button type="button" class="floating-cart-btn" id="floatingCartBtn" style="display: none;">
|
||||
<span class="cart-label">선택된 API</span>
|
||||
<span class="cart-badge" id="cartBadge">
|
||||
<span class="cart-count">0</span>
|
||||
<span class="cart-unit">개</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<!-- Selected APIs Modal - Uses base modal component -->
|
||||
<div class="modal" id="selectedApisModal" style="display: none;">
|
||||
<div class="modal-backdrop" id="modalOverlay"></div>
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title">선택된 API 목록</h3>
|
||||
<button type="button" class="modal-close" id="modalCloseBtn">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="selected-apis-list" id="modalSelectedList">
|
||||
<!-- Dynamically populated -->
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" id="modalCancelBtn">닫기</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</th:block>
|
||||
|
||||
<th:block layout:fragment="contentScript">
|
||||
<script th:inline="javascript">
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// DOM Elements
|
||||
const form = document.getElementById('registerStep2Form');
|
||||
const searchInput = document.getElementById('apiSearch');
|
||||
const sidebar = document.getElementById('apiSidebar');
|
||||
const mobileToggle = document.getElementById('mobileToggle');
|
||||
const mobileOverlay = document.getElementById('mobileOverlay');
|
||||
const menuTitles = document.querySelectorAll('.menu-title');
|
||||
const apiCardGrid = document.getElementById('apiCardGrid');
|
||||
const loadingState = document.getElementById('loadingState');
|
||||
const emptyState = document.getElementById('emptyState');
|
||||
|
||||
let currentFilter = ''; // Empty string means "all"
|
||||
let currentServiceName = '전체'; // Store current service name
|
||||
let allApis = []; // Store loaded APIs
|
||||
let selectedApis = new Set(); // Track selected API IDs
|
||||
|
||||
// Restore selected APIs from session
|
||||
const sessionSelectedApis = /*[[${apiKeyRegistration.selectedApis}]]*/ [];
|
||||
if (sessionSelectedApis && Array.isArray(sessionSelectedApis)) {
|
||||
sessionSelectedApis.forEach(function(apiId) {
|
||||
selectedApis.add(apiId);
|
||||
});
|
||||
}
|
||||
|
||||
// Load APIs via AJAX
|
||||
function loadApis(groupId) {
|
||||
// Show loading state
|
||||
loadingState.style.display = 'block';
|
||||
emptyState.style.display = 'none';
|
||||
|
||||
// Remove existing API cards
|
||||
document.querySelectorAll('.api-selection-card').forEach(card => card.remove());
|
||||
|
||||
// Build URL with optional groupId filter
|
||||
let url = '/apis/for_request';
|
||||
if (groupId) {
|
||||
url += '?groupIds=' + encodeURIComponent(groupId);
|
||||
}
|
||||
|
||||
fetch(url).then(response => response.json()).then(apis => {
|
||||
allApis = apis;
|
||||
loadingState.style.display = 'none';
|
||||
|
||||
if (apis.length === 0) {
|
||||
emptyState.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
renderApiCards(apis);
|
||||
updateSelectAllUI();
|
||||
}).catch(error => {
|
||||
console.error('Failed to load APIs:', error);
|
||||
loadingState.style.display = 'none';
|
||||
emptyState.querySelector('h3').textContent = 'API 로드 실패';
|
||||
emptyState.querySelector('p').textContent = '다시 시도해주세요.';
|
||||
emptyState.style.display = 'block';
|
||||
});
|
||||
}
|
||||
|
||||
// Render API cards
|
||||
function renderApiCards(apis) {
|
||||
const fragment = document.createDocumentFragment();
|
||||
|
||||
apis.forEach(api => {
|
||||
const card = createApiCard(api);
|
||||
fragment.appendChild(card);
|
||||
});
|
||||
|
||||
apiCardGrid.appendChild(fragment);
|
||||
|
||||
// Re-attach event listeners
|
||||
attachCardEventListeners();
|
||||
}
|
||||
|
||||
// Create API card element
|
||||
function createApiCard(api) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'api-selection-card';
|
||||
card.setAttribute('data-group', api.apiGroupId || '');
|
||||
card.setAttribute('data-name', (api.apiName || '').toLowerCase());
|
||||
card.setAttribute('data-desc', (api.apiSimpleDescription || '').toLowerCase());
|
||||
card.setAttribute('data-api-id', api.apiId);
|
||||
|
||||
const isSelected = selectedApis.has(api.apiId);
|
||||
if (isSelected) {
|
||||
card.classList.add('selected');
|
||||
}
|
||||
|
||||
const mainIconHtml = api.mainIcon
|
||||
? `<img src="${api.mainIcon}" alt="${api.apiName}" onerror="this.style.display='none'; this.nextElementSibling.style.display='block'"><i class="fas fa-cube" style="display:none"></i>`
|
||||
: `<i class="fas fa-cube"></i>`;
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="api-card-content">
|
||||
<div class="api-card-header">
|
||||
<span class="api-card-category">${api.service || '카테고리'}</span>
|
||||
<div class="checkbox-wrapper">
|
||||
<input type="checkbox"
|
||||
name="selectedApis"
|
||||
value="${api.apiId}"
|
||||
id="api-${api.apiId}"
|
||||
class="api-checkbox visually-hidden"
|
||||
${isSelected ? 'checked' : ''}>
|
||||
<span class="custom-checkbox"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="api-name">${api.apiName || 'API 이름'}</h3>
|
||||
<p class="api-description">${api.apiSimpleDescription || 'API 설명이 없습니다.'}</p>
|
||||
|
||||
<div class="api-card-icon">
|
||||
${mainIconHtml}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
// Attach event listeners to cards
|
||||
function attachCardEventListeners() {
|
||||
const checkboxes = document.querySelectorAll('.api-checkbox');
|
||||
const apiCards = document.querySelectorAll('.api-selection-card');
|
||||
|
||||
checkboxes.forEach(checkbox => {
|
||||
checkbox.addEventListener('change', function() {
|
||||
updateCardSelection(this);
|
||||
updateSelectedCount();
|
||||
});
|
||||
});
|
||||
|
||||
apiCards.forEach(card => {
|
||||
card.addEventListener('click', function(e) {
|
||||
// Prevent double toggle if clicking directly on checkbox
|
||||
if (e.target.classList.contains('api-checkbox')) {
|
||||
return;
|
||||
}
|
||||
const checkbox = card.querySelector('.api-checkbox');
|
||||
if (checkbox) {
|
||||
checkbox.checked = !checkbox.checked;
|
||||
updateCardSelection(checkbox);
|
||||
updateSelectedCount();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Update card visual state
|
||||
function updateCardSelection(checkbox) {
|
||||
const card = checkbox.closest('.api-selection-card');
|
||||
if (checkbox.checked) {
|
||||
card.classList.add('selected');
|
||||
selectedApis.add(checkbox.value);
|
||||
} else {
|
||||
card.classList.remove('selected');
|
||||
selectedApis.delete(checkbox.value);
|
||||
}
|
||||
}
|
||||
|
||||
// Update selected count
|
||||
function updateSelectedCount() {
|
||||
// Update floating cart button
|
||||
const floatingCartBtn = document.getElementById('floatingCartBtn');
|
||||
const cartCount = document.querySelector('.cart-count');
|
||||
|
||||
if (selectedApis.size > 0) {
|
||||
floatingCartBtn.style.display = 'flex';
|
||||
cartCount.textContent = selectedApis.size;
|
||||
} else {
|
||||
floatingCartBtn.style.display = 'none';
|
||||
}
|
||||
|
||||
// Update modal list
|
||||
updateModalList();
|
||||
|
||||
// Update select all checkbox state
|
||||
updateSelectAllCheckboxState();
|
||||
}
|
||||
|
||||
// Update select all UI visibility and text
|
||||
function updateSelectAllUI() {
|
||||
const selectAllWrapper = document.getElementById('selectAllWrapper');
|
||||
const selectAllText = document.getElementById('selectAllText');
|
||||
|
||||
if (currentFilter === '') {
|
||||
// Hide select all when "전체" is selected
|
||||
selectAllWrapper.style.display = 'none';
|
||||
} else {
|
||||
// Show select all with service name
|
||||
selectAllWrapper.style.display = 'flex';
|
||||
selectAllText.textContent = currentServiceName + ' API 전체 선택';
|
||||
}
|
||||
|
||||
updateSelectAllCheckboxState();
|
||||
}
|
||||
|
||||
// Update select all checkbox state based on visible cards
|
||||
function updateSelectAllCheckboxState() {
|
||||
const selectAllCheckbox = document.getElementById('selectAllCheckbox');
|
||||
const visibleCards = Array.from(document.querySelectorAll('.api-selection-card')).filter(card => card.style.display !== 'none');
|
||||
|
||||
if (visibleCards.length === 0) {
|
||||
selectAllCheckbox.checked = false;
|
||||
selectAllCheckbox.indeterminate = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const visibleCheckboxes = visibleCards.map(card => card.querySelector('.api-checkbox'));
|
||||
const checkedCount = visibleCheckboxes.filter(cb => cb.checked).length;
|
||||
|
||||
if (checkedCount === 0) {
|
||||
selectAllCheckbox.checked = false;
|
||||
selectAllCheckbox.indeterminate = false;
|
||||
} else if (checkedCount === visibleCheckboxes.length) {
|
||||
selectAllCheckbox.checked = true;
|
||||
selectAllCheckbox.indeterminate = false;
|
||||
} else {
|
||||
selectAllCheckbox.checked = false;
|
||||
selectAllCheckbox.indeterminate = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Update modal selected APIs list
|
||||
function updateModalList() {
|
||||
const modalSelectedList = document.getElementById('modalSelectedList');
|
||||
modalSelectedList.innerHTML = '';
|
||||
|
||||
if (selectedApis.size === 0) {
|
||||
modalSelectedList.innerHTML = '<p class="empty-message">선택된 API가 없습니다.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Get all checked checkboxes
|
||||
const checkedBoxes = document.querySelectorAll('.api-checkbox:checked');
|
||||
checkedBoxes.forEach(function(checkbox) {
|
||||
const card = checkbox.closest('.api-selection-card');
|
||||
const apiName = card.querySelector('.api-name').textContent;
|
||||
|
||||
const apiPill = document.createElement('div');
|
||||
apiPill.className = 'api-pill';
|
||||
apiPill.innerHTML = `
|
||||
<span class="api-pill-name">${apiName}</span>
|
||||
<button type="button" class="api-pill-remove" data-value="${checkbox.value}" aria-label="Remove ${apiName}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
`;
|
||||
modalSelectedList.appendChild(apiPill);
|
||||
});
|
||||
|
||||
// Add remove handlers
|
||||
document.querySelectorAll('.api-pill-remove').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
const value = this.getAttribute('data-value');
|
||||
const checkbox = document.querySelector('.api-checkbox[value="' + value + '"]');
|
||||
if (checkbox) {
|
||||
checkbox.checked = false;
|
||||
updateCardSelection(checkbox);
|
||||
updateSelectedCount();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Search functionality
|
||||
if (searchInput) {
|
||||
searchInput.addEventListener('input', function() {
|
||||
const searchTerm = this.value.toLowerCase();
|
||||
|
||||
const apiCards = document.querySelectorAll('.api-selection-card');
|
||||
apiCards.forEach(function(card) {
|
||||
const apiName = card.getAttribute('data-name');
|
||||
const apiDesc = card.getAttribute('data-desc');
|
||||
|
||||
// Check if matches search
|
||||
const matchesSearch = apiName.includes(searchTerm) || apiDesc.includes(searchTerm);
|
||||
|
||||
if (matchesSearch) {
|
||||
card.style.display = '';
|
||||
} else {
|
||||
card.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
// Update select all checkbox state after search
|
||||
updateSelectAllCheckboxState();
|
||||
});
|
||||
}
|
||||
|
||||
// Sidebar category selection
|
||||
menuTitles.forEach(function(title) {
|
||||
title.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
// Remove active from all
|
||||
menuTitles.forEach(t => t.classList.remove('active'));
|
||||
// Add active to clicked
|
||||
this.classList.add('active');
|
||||
|
||||
const groupId = this.getAttribute('data-group');
|
||||
currentFilter = groupId;
|
||||
|
||||
// Store service name for select all label
|
||||
currentServiceName = this.textContent.trim().split('\n')[0].trim();
|
||||
|
||||
// Load APIs for selected service
|
||||
loadApis(groupId);
|
||||
|
||||
// Clear search when changing category
|
||||
if (searchInput) {
|
||||
searchInput.value = '';
|
||||
}
|
||||
|
||||
// Close mobile menu if open
|
||||
if (window.innerWidth <= 768 && sidebar.classList.contains('mobile-open')) {
|
||||
closeMobileMenu();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Mobile menu toggle
|
||||
function closeMobileMenu() {
|
||||
sidebar.classList.remove('mobile-open');
|
||||
mobileOverlay.classList.remove('active');
|
||||
mobileToggle.innerHTML = '☰';
|
||||
mobileToggle.setAttribute('aria-label', '메뉴 열기');
|
||||
}
|
||||
|
||||
if (mobileToggle && sidebar && mobileOverlay) {
|
||||
mobileToggle.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
sidebar.classList.toggle('mobile-open');
|
||||
mobileOverlay.classList.toggle('active');
|
||||
|
||||
if (sidebar.classList.contains('mobile-open')) {
|
||||
this.innerHTML = '✕';
|
||||
this.setAttribute('aria-label', '메뉴 닫기');
|
||||
} else {
|
||||
this.innerHTML = '☰';
|
||||
this.setAttribute('aria-label', '메뉴 열기');
|
||||
}
|
||||
});
|
||||
|
||||
// Close menu on overlay click
|
||||
mobileOverlay.addEventListener('click', function() {
|
||||
closeMobileMenu();
|
||||
});
|
||||
}
|
||||
|
||||
// Close mobile menu on resize to desktop
|
||||
window.addEventListener('resize', function() {
|
||||
if (window.innerWidth > 768 && sidebar.classList.contains('mobile-open')) {
|
||||
closeMobileMenu();
|
||||
}
|
||||
});
|
||||
|
||||
// Modal control
|
||||
const floatingCartBtn = document.getElementById('floatingCartBtn');
|
||||
const selectedApisModal = document.getElementById('selectedApisModal');
|
||||
const modalOverlay = document.getElementById('modalOverlay');
|
||||
const modalCloseBtn = document.getElementById('modalCloseBtn');
|
||||
const modalCancelBtn = document.getElementById('modalCancelBtn');
|
||||
|
||||
// Open modal
|
||||
function openModal() {
|
||||
selectedApisModal.style.display = 'block';
|
||||
// Add show class to backdrop and modal for visibility
|
||||
setTimeout(function() {
|
||||
if (modalOverlay) {
|
||||
modalOverlay.classList.add('show');
|
||||
}
|
||||
selectedApisModal.classList.add('show');
|
||||
}, 10);
|
||||
document.body.style.overflow = 'hidden'; // Prevent background scroll
|
||||
}
|
||||
|
||||
// Close modal
|
||||
function closeModal() {
|
||||
// Remove show class first for transition
|
||||
if (modalOverlay) {
|
||||
modalOverlay.classList.remove('show');
|
||||
}
|
||||
selectedApisModal.classList.remove('show');
|
||||
|
||||
// Hide modal after transition
|
||||
setTimeout(function() {
|
||||
selectedApisModal.style.display = 'none';
|
||||
}, 300);
|
||||
|
||||
document.body.style.overflow = ''; // Restore scroll
|
||||
}
|
||||
|
||||
// Floating cart button click
|
||||
if (floatingCartBtn) {
|
||||
floatingCartBtn.addEventListener('click', openModal);
|
||||
}
|
||||
|
||||
// Modal overlay click
|
||||
if (modalOverlay) {
|
||||
modalOverlay.addEventListener('click', closeModal);
|
||||
}
|
||||
|
||||
// Modal close button click
|
||||
if (modalCloseBtn) {
|
||||
modalCloseBtn.addEventListener('click', closeModal);
|
||||
}
|
||||
|
||||
// Modal cancel button click
|
||||
if (modalCancelBtn) {
|
||||
modalCancelBtn.addEventListener('click', closeModal);
|
||||
}
|
||||
|
||||
// Close modal on ESC key
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape' && selectedApisModal.style.display === 'block') {
|
||||
closeModal();
|
||||
}
|
||||
});
|
||||
|
||||
// Select All checkbox event
|
||||
const selectAllCheckbox = document.getElementById('selectAllCheckbox');
|
||||
if (selectAllCheckbox) {
|
||||
selectAllCheckbox.addEventListener('change', function() {
|
||||
const isChecked = this.checked;
|
||||
const visibleCards = Array.from(document.querySelectorAll('.api-selection-card')).filter(card => card.style.display !== 'none');
|
||||
|
||||
visibleCards.forEach(function(card) {
|
||||
const checkbox = card.querySelector('.api-checkbox');
|
||||
if (checkbox) {
|
||||
checkbox.checked = isChecked;
|
||||
updateCardSelection(checkbox);
|
||||
}
|
||||
});
|
||||
|
||||
updateSelectedCount();
|
||||
});
|
||||
}
|
||||
|
||||
// Previous step button - save current selections before going back
|
||||
const btnPrevStep = document.getElementById('btnPrevStep');
|
||||
if (btnPrevStep) {
|
||||
btnPrevStep.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
// Change form action to save endpoint
|
||||
const originalAction = form.action;
|
||||
form.action = '/myapikey/register/step2/save';
|
||||
|
||||
// Submit the form to save selections
|
||||
form.submit();
|
||||
});
|
||||
}
|
||||
|
||||
// Form validation
|
||||
form.addEventListener('submit', function(e) {
|
||||
if (selectedApis.size === 0) {
|
||||
e.preventDefault();
|
||||
customPopups.showAlert('최소 1개 이상의 API를 선택해주세요.');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Store selected APIs in sessionStorage
|
||||
sessionStorage.setItem('registration_selectedApis', JSON.stringify(Array.from(selectedApis)));
|
||||
});
|
||||
|
||||
// Initialize
|
||||
updateSelectedCount(); // This will show count from restored session data
|
||||
|
||||
// Load all APIs automatically on page load (empty string = all APIs)
|
||||
loadApis('');
|
||||
|
||||
// Load data from step 1 (if needed for display)
|
||||
const appName = sessionStorage.getItem('registration_appName');
|
||||
if (appName) {
|
||||
console.log('App Name from Step 1:', appName);
|
||||
}
|
||||
|
||||
// Log restored selections for debugging
|
||||
if (selectedApis.size > 0) {
|
||||
console.log('Restored ' + selectedApis.size + ' selected APIs from session:', Array.from(selectedApis));
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</th:block>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,469 @@
|
||||
<!doctype html>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
|
||||
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/djbank_title_layout}">
|
||||
|
||||
<body>
|
||||
|
||||
<th:block layout:fragment="contentFragment">
|
||||
<section class="oauth2-2legged">
|
||||
|
||||
<!-- Section 1: Hero -->
|
||||
<header class="oauth2-2legged__hero">
|
||||
<div class="oauth2-2legged__hero-body">
|
||||
<span class="oauth2-2legged__hero-eyebrow">
|
||||
<span class="oauth2-2legged__hero-eyebrow-dot"></span>
|
||||
개발 가이드 · Webhook · HMAC-SHA256
|
||||
</span>
|
||||
<h1 class="oauth2-2legged__hero-title">웹훅 개발가이드</h1>
|
||||
<p class="oauth2-2legged__hero-lead">DJBank가 발송하는 Webhook 요청의 진위를 확인하기 위한</p>
|
||||
<p class="oauth2-2legged__hero-lead">HMAC-SHA256 서명 검증 방법을 단계별로 설명합니다.</p>
|
||||
|
||||
<div class="oauth2-2legged__hero-chips">
|
||||
<span class="oauth2-2legged__chip oauth2-2legged__chip--primary">HMAC-SHA256</span>
|
||||
<span class="oauth2-2legged__chip">X-Webhook-Signature</span>
|
||||
<span class="oauth2-2legged__chip">Raw Body</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="oauth2-2legged__hero-illust" aria-hidden="true">
|
||||
<svg viewBox="0 0 280 200" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Signed Webhook Delivery">
|
||||
<defs>
|
||||
<marker id="whsig-hero-arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto">
|
||||
<path d="M0,0 L10,5 L0,10 Z" fill="#0049b4"/>
|
||||
</marker>
|
||||
</defs>
|
||||
<rect x="0" y="0" width="280" height="200" rx="20" fill="#FFFFFF"/>
|
||||
|
||||
<rect x="24" y="92" width="86" height="52" rx="8" fill="#EDF9FE" stroke="#0049b4"/>
|
||||
<text x="67" y="114" text-anchor="middle" font-size="10" font-weight="700" fill="#0049b4">DJBank</text>
|
||||
<text x="67" y="128" text-anchor="middle" font-size="10" font-weight="700" fill="#0049b4">Webhook</text>
|
||||
|
||||
<rect x="170" y="92" width="86" height="52" rx="8" fill="#EDF9FE" stroke="#0049b4"/>
|
||||
<text x="213" y="114" text-anchor="middle" font-size="10" font-weight="700" fill="#0049b4">Your</text>
|
||||
<text x="213" y="128" text-anchor="middle" font-size="10" font-weight="700" fill="#0049b4">Endpoint</text>
|
||||
|
||||
<line x1="110" y1="118" x2="170" y2="118" stroke="#0049b4" stroke-width="2" marker-end="url(#whsig-hero-arrow)"/>
|
||||
|
||||
<g transform="translate(58,28)">
|
||||
<rect width="164" height="34" rx="6" fill="#1A1A2E"/>
|
||||
<text x="12" y="16" font-family="'Fira Code', monospace" font-size="9" fill="#00D4FF">X-Webhook-Signature:</text>
|
||||
<text x="12" y="28" font-family="'Fira Code', monospace" font-size="9" fill="#FFD93D">sha256=9f86d0..</text>
|
||||
</g>
|
||||
|
||||
<g transform="translate(133,104)">
|
||||
<circle r="13" fill="#0049b4"/>
|
||||
<path d="M-5,-1 h10 v7 h-10 z M-3,-1 v-3 a3,3 0 0 1 6,0 v3" fill="none" stroke="#FFFFFF" stroke-width="1.5"/>
|
||||
</g>
|
||||
|
||||
<text x="140" y="172" text-anchor="middle" font-size="11" font-weight="600" fill="#64748B">Signed Webhook Delivery</text>
|
||||
</svg>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Section 2: 개요 -->
|
||||
<section class="oauth2-2legged__prereq" aria-labelledby="whsig-prereq-title">
|
||||
<span class="oauth2-2legged__eyebrow">OVERVIEW</span>
|
||||
<h2 class="oauth2-2legged__h2" id="whsig-prereq-title">서명 검증이 필요한 이유</h2>
|
||||
|
||||
<div class="oauth2-2legged__prereq-grid">
|
||||
<article class="oauth2-2legged__prereq-card">
|
||||
<span class="oauth2-2legged__prereq-num">1</span>
|
||||
<div class="oauth2-2legged__prereq-body">
|
||||
<h3 class="oauth2-2legged__prereq-title">Secret 확보</h3>
|
||||
<p>[마이페이지 > Webhook 관리]에서 발급된</p>
|
||||
<p>Secret Key를 서버에 안전하게 보관.</p>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="oauth2-2legged__prereq-card">
|
||||
<span class="oauth2-2legged__prereq-num">2</span>
|
||||
<div class="oauth2-2legged__prereq-body">
|
||||
<h3 class="oauth2-2legged__prereq-title">원문(raw body) 보존</h3>
|
||||
<p>수신 즉시 본문을 파싱/재직렬화하지 말고</p>
|
||||
<p>바이트 원문 그대로 서명 계산에 사용.</p>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="oauth2-2legged__prereq-card">
|
||||
<span class="oauth2-2legged__prereq-num">3</span>
|
||||
<div class="oauth2-2legged__prereq-body">
|
||||
<h3 class="oauth2-2legged__prereq-title">HMAC 재계산·비교</h3>
|
||||
<p>동일 Secret으로 HMAC-SHA256을 재계산해</p>
|
||||
<p>헤더 서명과 상수 시간으로 비교.</p>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Section 3: 전체 서명·검증 시퀀스 -->
|
||||
<section class="oauth2-2legged__sequence" aria-labelledby="whsig-seq-title">
|
||||
<span class="oauth2-2legged__eyebrow">SEQUENCE</span>
|
||||
<h2 class="oauth2-2legged__h2" id="whsig-seq-title">전체 서명·검증 시퀀스</h2>
|
||||
|
||||
<div class="oauth2-2legged__sequence-diagram">
|
||||
<svg viewBox="0 0 1120 300" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Webhook Signature Verification Sequence">
|
||||
<defs>
|
||||
<marker id="whsig-arrow-primary" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto">
|
||||
<path d="M0,0 L10,5 L0,10 Z" fill="#0049b4"/>
|
||||
</marker>
|
||||
<marker id="whsig-arrow-gray" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto">
|
||||
<path d="M0,0 L10,5 L0,10 Z" fill="#64748B"/>
|
||||
</marker>
|
||||
</defs>
|
||||
|
||||
<g>
|
||||
<rect x="120" y="24" width="240" height="48" rx="24" fill="#EDF9FE" stroke="#0049b4"/>
|
||||
<text x="240" y="54" text-anchor="middle" font-size="14" font-weight="700" fill="#0049b4">DJBank Webhook Sender</text>
|
||||
<line x1="240" y1="72" x2="240" y2="272" stroke="#94A3B8" stroke-dasharray="4 4"/>
|
||||
</g>
|
||||
<g>
|
||||
<rect x="760" y="24" width="240" height="48" rx="24" fill="#FFFFFF" stroke="#0049b4"/>
|
||||
<text x="880" y="54" text-anchor="middle" font-size="14" font-weight="700" fill="#0049b4">Your Endpoint</text>
|
||||
<line x1="880" y1="72" x2="880" y2="272" stroke="#94A3B8" stroke-dasharray="4 4"/>
|
||||
</g>
|
||||
|
||||
<text x="240" y="104" text-anchor="middle" font-size="12" font-weight="700" fill="#64748B">① 이벤트 발생 (점검·지연·장애)</text>
|
||||
<text x="240" y="124" text-anchor="middle" font-size="12" font-weight="700" fill="#64748B">② signature = HMAC-SHA256(secret, body)</text>
|
||||
|
||||
<text x="560" y="156" text-anchor="middle" font-size="12" font-weight="700" fill="#0049b4">③ POST body · X-Webhook-Signature: sha256=<hex></text>
|
||||
<line x1="240" y1="166" x2="880" y2="166" stroke="#0049b4" stroke-width="2" marker-end="url(#whsig-arrow-primary)"/>
|
||||
|
||||
<text x="880" y="198" text-anchor="middle" font-size="12" font-weight="700" fill="#64748B">④ 동일 secret으로 재계산</text>
|
||||
<text x="880" y="218" text-anchor="middle" font-size="12" font-weight="700" fill="#64748B">⑤ 상수 시간 비교 (일치 여부)</text>
|
||||
|
||||
<text x="560" y="250" text-anchor="middle" font-size="12" font-weight="700" fill="#64748B">⑥ 200 OK (검증 성공 시)</text>
|
||||
<line x1="880" y1="260" x2="240" y2="260" stroke="#64748B" stroke-width="2" marker-end="url(#whsig-arrow-gray)"/>
|
||||
</svg>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Section 4: STEP 1 — 수신 요청 형식 -->
|
||||
<section class="oauth2-2legged__step" aria-labelledby="whsig-step1-title">
|
||||
<span class="oauth2-2legged__eyebrow">STEP 1</span>
|
||||
<h2 class="oauth2-2legged__h2" id="whsig-step1-title">수신 요청 형식</h2>
|
||||
<p class="oauth2-2legged__desc">DJBank는 등록한 수신 URL로 아래 형태의 POST 요청을 전송합니다.</p>
|
||||
|
||||
<div class="oauth2-2legged__endpoint-box">
|
||||
<span class="oauth2-2legged__method">POST</span>
|
||||
<code class="oauth2-2legged__endpoint-path">https://your-service.example.com/webhook</code>
|
||||
<span class="oauth2-2legged__endpoint-content-type">application/json</span>
|
||||
</div>
|
||||
|
||||
<div class="oauth2-2legged__step-grid">
|
||||
<div class="oauth2-2legged__panel">
|
||||
<h3 class="oauth2-2legged__panel-title">요청 헤더</h3>
|
||||
<table class="oauth2-2legged__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>HEADER</th>
|
||||
<th>설명</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>X-Webhook-Signature</code></td>
|
||||
<td><code>sha256=<hex></code> — 본문 HMAC-SHA256 서명(소문자 hex)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>X-Webhook-Event</code></td>
|
||||
<td>이벤트 코드 (예: CONTROL_START)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>X-Webhook-Timestamp</code></td>
|
||||
<td>발송 시각 (epoch millis) — 재전송 방어용</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>Content-Type</code></td>
|
||||
<td>application/json</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="oauth2-2legged__warning">⚠ 서명 대상은 파싱 전 <strong>본문 원문(raw body)</strong> 입니다.</p>
|
||||
</div>
|
||||
|
||||
<div class="oauth2-2legged__code-panel">
|
||||
<span class="oauth2-2legged__code-tag">Request Body · JSON</span>
|
||||
<pre class="oauth2-2legged__code-block">{
|
||||
<span class="o2leg-c">"eventType"</span>: <span class="o2leg-y">"CONTROL_START"</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">"data"</span>: [ <span class="o2leg-y">"TESTCASE003S1"</span>, <span class="o2leg-y">"TESTCASE005S1"</span> ]
|
||||
}
|
||||
|
||||
<span class="o2leg-g"># eventId: 발송 건 고유 ID · data: 영향 API 목록</span></pre>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Section 5: STEP 2 — 서명 검증 알고리즘 -->
|
||||
<section class="oauth2-2legged__step" aria-labelledby="whsig-step2-title">
|
||||
<span class="oauth2-2legged__eyebrow">STEP 2</span>
|
||||
<h2 class="oauth2-2legged__h2" id="whsig-step2-title">서명 검증 알고리즘</h2>
|
||||
<p class="oauth2-2legged__desc">수신한 본문 원문과 발급된 Secret으로 서명을 재계산해 헤더 값과 비교합니다.</p>
|
||||
|
||||
<div class="oauth2-2legged__step-grid">
|
||||
<div class="oauth2-2legged__code-panel">
|
||||
<span class="oauth2-2legged__code-tag">Verification Steps</span>
|
||||
<pre class="oauth2-2legged__code-block"><span class="o2leg-cm"># 1) 헤더에서 서명 추출</span>
|
||||
received = header[<span class="o2leg-c">"X-Webhook-Signature"</span>] <span class="o2leg-g"># "sha256=...."</span>
|
||||
|
||||
<span class="o2leg-cm"># 2) 본문 원문으로 HMAC-SHA256 재계산</span>
|
||||
digest = HMAC_SHA256(secret, rawBody) <span class="o2leg-g"># bytes</span>
|
||||
expected = <span class="o2leg-y">"sha256="</span> + toHexLower(digest)
|
||||
|
||||
<span class="o2leg-cm"># 3) 상수 시간 비교</span>
|
||||
valid = constantTimeEquals(received, expected)</pre>
|
||||
</div>
|
||||
|
||||
<div class="oauth2-2legged__panel">
|
||||
<h3 class="oauth2-2legged__panel-title">검증 규칙</h3>
|
||||
<ul class="oauth2-2legged__tips">
|
||||
<li>알고리즘: <code>HmacSHA256</code>, 키: Secret(UTF-8 bytes)</li>
|
||||
<li>메시지: 수신 <strong>본문 원문</strong>(UTF-8 bytes)</li>
|
||||
<li>출력: <strong>소문자 hex</strong>, 헤더는 <code>sha256=</code> 접두 포함</li>
|
||||
<li>비교: 타이밍 공격 방지를 위해 <strong>상수 시간</strong> 비교</li>
|
||||
<li>불일치 시 요청을 폐기하고 2xx 이외로 응답</li>
|
||||
</ul>
|
||||
<h4 class="oauth2-2legged__panel-subtitle">재전송(replay) 방어</h4>
|
||||
<ul class="oauth2-2legged__tips">
|
||||
<li><code>X-Webhook-Timestamp</code>가 허용 오차(예: 5분) 밖이면 거부</li>
|
||||
<li>동일 <code>eventId</code> 중복 수신은 멱등 처리</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Section 6: STEP 3 — 언어별 예제 -->
|
||||
<section class="oauth2-2legged__step" aria-labelledby="whsig-step3-title">
|
||||
<span class="oauth2-2legged__eyebrow">STEP 3</span>
|
||||
<h2 class="oauth2-2legged__h2" id="whsig-step3-title">언어별 서명 검증 예제</h2>
|
||||
<p class="oauth2-2legged__desc">프레임워크에서 반드시 <strong>원문 바디</strong>에 접근할 수 있어야 합니다.</p>
|
||||
|
||||
<div class="oauth2-2legged__step-grid">
|
||||
<div class="oauth2-2legged__code-panel">
|
||||
<span class="oauth2-2legged__code-tag">Node.js (Express)</span>
|
||||
<pre class="oauth2-2legged__code-block"><span class="o2leg-p">const</span> crypto = <span class="o2leg-y">require</span>(<span class="o2leg-c">'crypto'</span>);
|
||||
|
||||
<span class="o2leg-cm">// rawBody: express.raw() 등으로 확보한 원문 Buffer</span>
|
||||
<span class="o2leg-p">function</span> <span class="o2leg-y">verify</span>(rawBody, header, secret) {
|
||||
<span class="o2leg-p">const</span> expected = <span class="o2leg-c">'sha256='</span> +
|
||||
crypto.<span class="o2leg-y">createHmac</span>(<span class="o2leg-c">'sha256'</span>, secret)
|
||||
.<span class="o2leg-y">update</span>(rawBody)
|
||||
.<span class="o2leg-y">digest</span>(<span class="o2leg-c">'hex'</span>);
|
||||
<span class="o2leg-p">const</span> a = Buffer.<span class="o2leg-y">from</span>(header);
|
||||
<span class="o2leg-p">const</span> b = Buffer.<span class="o2leg-y">from</span>(expected);
|
||||
<span class="o2leg-p">return</span> a.length === b.length &&
|
||||
crypto.<span class="o2leg-y">timingSafeEqual</span>(a, b);
|
||||
}</pre>
|
||||
</div>
|
||||
|
||||
<div class="oauth2-2legged__code-panel">
|
||||
<span class="oauth2-2legged__code-tag">Python (Flask)</span>
|
||||
<pre class="oauth2-2legged__code-block"><span class="o2leg-p">import</span> hmac, hashlib
|
||||
|
||||
<span class="o2leg-cm"># raw_body: request.get_data() 로 확보한 bytes</span>
|
||||
<span class="o2leg-p">def</span> <span class="o2leg-y">verify</span>(raw_body, header, secret):
|
||||
digest = hmac.<span class="o2leg-y">new</span>(
|
||||
secret.<span class="o2leg-y">encode</span>(<span class="o2leg-c">'utf-8'</span>),
|
||||
raw_body,
|
||||
hashlib.sha256
|
||||
).<span class="o2leg-y">hexdigest</span>()
|
||||
expected = <span class="o2leg-c">'sha256='</span> + digest
|
||||
<span class="o2leg-p">return</span> hmac.<span class="o2leg-y">compare_digest</span>(expected, header)</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="oauth2-2legged__step-grid">
|
||||
<div class="oauth2-2legged__code-panel">
|
||||
<span class="oauth2-2legged__code-tag">Java</span>
|
||||
<pre class="oauth2-2legged__code-block"><span class="o2leg-p">import</span> javax.crypto.Mac;
|
||||
<span class="o2leg-p">import</span> javax.crypto.spec.SecretKeySpec;
|
||||
<span class="o2leg-p">import</span> java.nio.charset.StandardCharsets;
|
||||
<span class="o2leg-p">import</span> java.security.MessageDigest;
|
||||
|
||||
<span class="o2leg-cm">// rawBody: 파싱 전 원문 문자열</span>
|
||||
<span class="o2leg-p">boolean</span> <span class="o2leg-y">verify</span>(String rawBody, String header, String secret) <span class="o2leg-p">throws</span> Exception {
|
||||
Mac mac = Mac.<span class="o2leg-y">getInstance</span>(<span class="o2leg-c">"HmacSHA256"</span>);
|
||||
mac.<span class="o2leg-y">init</span>(<span class="o2leg-p">new</span> SecretKeySpec(secret.<span class="o2leg-y">getBytes</span>(StandardCharsets.UTF_8), <span class="o2leg-c">"HmacSHA256"</span>));
|
||||
<span class="o2leg-p">byte</span>[] hash = mac.<span class="o2leg-y">doFinal</span>(rawBody.<span class="o2leg-y">getBytes</span>(StandardCharsets.UTF_8));
|
||||
StringBuilder hex = <span class="o2leg-p">new</span> StringBuilder();
|
||||
<span class="o2leg-p">for</span> (<span class="o2leg-p">byte</span> b : hash) hex.<span class="o2leg-y">append</span>(String.<span class="o2leg-y">format</span>(<span class="o2leg-c">"%02x"</span>, b));
|
||||
String expected = <span class="o2leg-c">"sha256="</span> + hex;
|
||||
<span class="o2leg-p">return</span> MessageDigest.<span class="o2leg-y">isEqual</span>(
|
||||
expected.<span class="o2leg-y">getBytes</span>(StandardCharsets.UTF_8),
|
||||
header.<span class="o2leg-y">getBytes</span>(StandardCharsets.UTF_8));
|
||||
}</pre>
|
||||
</div>
|
||||
|
||||
<div class="oauth2-2legged__panel">
|
||||
<h3 class="oauth2-2legged__panel-title">이벤트 코드 (X-Webhook-Event)</h3>
|
||||
<table class="oauth2-2legged__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>CODE</th>
|
||||
<th>의미</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td><code>CONTROL_START</code></td><td>점검 시작</td></tr>
|
||||
<tr><td><code>CONTROL_END</code></td><td>점검 종료</td></tr>
|
||||
<tr><td><code>DELAY_START</code></td><td>지연 시작</td></tr>
|
||||
<tr><td><code>DELAY_END</code></td><td>지연 종료</td></tr>
|
||||
<tr><td><code>ERROR_START</code></td><td>장애 시작</td></tr>
|
||||
<tr><td><code>ERROR_END</code></td><td>장애 종료</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Section 6.5: STEP 4 — 응답 반환 규칙 -->
|
||||
<section class="oauth2-2legged__step" aria-labelledby="whsig-step4-title">
|
||||
<span class="oauth2-2legged__eyebrow">STEP 4</span>
|
||||
<h2 class="oauth2-2legged__h2" id="whsig-step4-title">응답(리턴) 반환 규칙</h2>
|
||||
<p class="oauth2-2legged__desc">수신 서버가 반환하는 HTTP 상태 코드에 따라 DJBank의 성공 판정과 재시도가 결정됩니다.</p>
|
||||
|
||||
<div class="oauth2-2legged__step-grid">
|
||||
<div class="oauth2-2legged__panel">
|
||||
<h3 class="oauth2-2legged__panel-title">상태 코드별 처리</h3>
|
||||
<table class="oauth2-2legged__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>반환</th>
|
||||
<th>상황</th>
|
||||
<th>DJBank 처리</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><span class="oauth2-2legged__req-badge oauth2-2legged__req-badge--required">2xx</span></td>
|
||||
<td>검증 성공 + 정상 접수</td>
|
||||
<td><strong>발송 성공</strong> 기록. 재시도 없음</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>400</code></td>
|
||||
<td>필수 헤더 누락</td>
|
||||
<td>실패 기록 + 재시도</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>401</code></td>
|
||||
<td>서명 불일치 / timestamp 만료</td>
|
||||
<td>실패 기록 + 재시도</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>5xx</code> · 타임아웃</td>
|
||||
<td>수신 서버 일시 장애</td>
|
||||
<td>실패 기록 + 재시도</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="oauth2-2legged__warning">⚠ 2xx 이외 응답과 네트워크 오류는 <strong>일정 간격을 두고 재시도</strong>됩니다(기본 3회). 재시도로 인한 중복 수신은 <code>eventId</code> 멱등 처리로 방어하세요.</p>
|
||||
|
||||
<h4 class="oauth2-2legged__panel-subtitle">응답 가이드</h4>
|
||||
<ul class="oauth2-2legged__tips">
|
||||
<li>검증 통과 시 <strong>즉시 200 OK</strong> 반환 — 무거운 후속 처리는 비동기로 분리</li>
|
||||
<li>응답 본문 규격은 자유(발송 로그에 기록만 됨) — 간단한 JSON 권장</li>
|
||||
<li>서명 검증 실패는 <code>401</code>, 필수 헤더 누락은 <code>400</code> 반환 권장</li>
|
||||
<li>동일 <code>eventId</code> 재수신 시 재처리 없이 200 반환(멱등)</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="oauth2-2legged__code-panel">
|
||||
<span class="oauth2-2legged__code-tag oauth2-2legged__code-tag--ok">200 OK · JSON (권장)</span>
|
||||
<pre class="oauth2-2legged__code-block"><span class="o2leg-cm"># 정상 접수</span>
|
||||
HTTP/1.1 <span class="o2leg-g">200 OK</span>
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
<span class="o2leg-c">"result"</span>: <span class="o2leg-y">"OK"</span>,
|
||||
<span class="o2leg-c">"eventType"</span>: <span class="o2leg-y">"CONTROL_START"</span>
|
||||
}
|
||||
|
||||
<span class="o2leg-cm"># 서명 검증 실패</span>
|
||||
HTTP/1.1 <span class="o2leg-g">401 Unauthorized</span>
|
||||
{
|
||||
<span class="o2leg-c">"result"</span>: <span class="o2leg-y">"ERROR"</span>,
|
||||
<span class="o2leg-c">"message"</span>: <span class="o2leg-y">"서명 검증에 실패하였습니다."</span>
|
||||
}
|
||||
|
||||
<span class="o2leg-cm"># 필수 헤더 누락</span>
|
||||
HTTP/1.1 <span class="o2leg-g">400 Bad Request</span>
|
||||
{
|
||||
<span class="o2leg-c">"result"</span>: <span class="o2leg-y">"ERROR"</span>,
|
||||
<span class="o2leg-c">"message"</span>: <span class="o2leg-y">"필수 헤더가 누락되었습니다."</span>
|
||||
}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Section 7: 검증 실패 대표 원인 -->
|
||||
<section class="oauth2-2legged__errors" aria-labelledby="whsig-errors-title">
|
||||
<span class="oauth2-2legged__eyebrow">TROUBLESHOOTING</span>
|
||||
<h2 class="oauth2-2legged__h2" id="whsig-errors-title">서명 불일치 대표 원인</h2>
|
||||
|
||||
<div class="oauth2-2legged__panel">
|
||||
<table class="oauth2-2legged__table oauth2-2legged__table--errors">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>원인</th>
|
||||
<th>증상</th>
|
||||
<th>해결 가이드</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>본문 재직렬화</td>
|
||||
<td>JSON 파싱 후 다시 문자열화한 값으로 서명 계산</td>
|
||||
<td>파싱 전 raw body(bytes)로 계산</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>hex 대소문자</td>
|
||||
<td>대문자 hex로 비교해 불일치</td>
|
||||
<td>소문자 hex 사용</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>접두어 처리</td>
|
||||
<td><code>sha256=</code> 접두 포함/제외 불일치</td>
|
||||
<td>양쪽 모두 접두 포함 후 비교</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>인코딩</td>
|
||||
<td>Secret/본문을 UTF-8 외로 인코딩</td>
|
||||
<td>키·메시지 모두 UTF-8 bytes</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Secret 불일치</td>
|
||||
<td>재발급 후 이전 Secret 사용</td>
|
||||
<td>최신 Secret으로 교체</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>재전송</td>
|
||||
<td>동일 이벤트 중복 수신</td>
|
||||
<td>timestamp 검사 + eventId 멱등 처리</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Section 8: CTA -->
|
||||
<a class="oauth2-2legged__cta" th:href="@{/webhook}">
|
||||
<div class="oauth2-2legged__cta-body">
|
||||
<span class="oauth2-2legged__cta-eyebrow">MANAGE</span>
|
||||
<h2 class="oauth2-2legged__cta-title">Webhook 관리로 가기</h2>
|
||||
<p class="oauth2-2legged__cta-desc">수신 URL·Secret·구독 이벤트를 등록하고 관리하세요.</p>
|
||||
<span class="oauth2-2legged__cta-button">Webhook 관리 →</span>
|
||||
</div>
|
||||
<span class="oauth2-2legged__cta-deco oauth2-2legged__cta-deco--lg" aria-hidden="true"></span>
|
||||
<span class="oauth2-2legged__cta-deco oauth2-2legged__cta-deco--sm" aria-hidden="true"></span>
|
||||
</a>
|
||||
|
||||
</section>
|
||||
</th:block>
|
||||
|
||||
</body>
|
||||
|
||||
<th:block layout:fragment="contentScript">
|
||||
</th:block>
|
||||
|
||||
</html>
|
||||
@@ -18,11 +18,20 @@
|
||||
<h3>등록된 Webhook이 없습니다</h3>
|
||||
<p>API 서비스의 점검·지연·장애 알림을 받을 Webhook을 신청해보세요.</p>
|
||||
|
||||
<div class="webhook-empty-action" sec:authorize="hasRole('ROLE_APP')">
|
||||
<div class="webhook-empty-action" sec:authorize="hasRole('ROLE_API_KEY_REQUEST')">
|
||||
<button type="button" class="btn-webhook-create" id="requestWebhook">
|
||||
Webhook 신청
|
||||
</button>
|
||||
</div>
|
||||
<p class="field-help" sec:authorize="!hasRole('ROLE_API_KEY_REQUEST')">
|
||||
Webhook 신청은 법인 관리자만 가능합니다. 기관 관리자에게 문의하세요.
|
||||
</p>
|
||||
|
||||
<p class="webhook-guide-link">
|
||||
<a th:href="@{/service/webhook-dev-guide}">
|
||||
📘 웹훅 개발가이드 — 수신·서명검증·응답 규칙 보러가기
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</th:block>
|
||||
|
||||
@@ -44,16 +44,24 @@
|
||||
<span class="row-label">Secret Key</span>
|
||||
<span class="row-value secret-row">
|
||||
<code id="secretMasked" th:text="*{secretMasked}">••••••••</code>
|
||||
<th:block sec:authorize="hasRole('ROLE_API_KEY_REQUEST')">
|
||||
<button type="button" class="btn-mini" id="btnRevealSecret">조회</button>
|
||||
<button type="button" class="btn-mini" id="btnRegenSecret">재발급</button>
|
||||
</th:block>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="webhook-card-actions">
|
||||
<div class="webhook-card-actions" sec:authorize="hasRole('ROLE_API_KEY_REQUEST')">
|
||||
<a class="btn-webhook-next" th:href="@{/webhook/modify/step1}">수정</a>
|
||||
<button type="button" class="btn-webhook-danger" id="btnDeleteWebhook">삭제</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="webhook-guide-link">
|
||||
<a th:href="@{/service/webhook-dev-guide}">
|
||||
📘 웹훅 개발가이드 — 수신·서명검증·응답 규칙 보러가기
|
||||
</a>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- 비밀번호 재인증 모달 -->
|
||||
@@ -118,7 +126,8 @@
|
||||
function showError(msg) { errorEl.textContent = msg; errorEl.style.display = 'block'; }
|
||||
|
||||
// Secret 조회
|
||||
document.getElementById('btnRevealSecret').addEventListener('click', function () {
|
||||
var btnReveal = document.getElementById('btnRevealSecret');
|
||||
if (btnReveal) btnReveal.addEventListener('click', function () {
|
||||
openModal('Secret 조회', '비밀번호 확인 후 Secret Key를 표시합니다.', function (pw) {
|
||||
post('/webhook/verify-secret', pw).then(function (res) {
|
||||
if (res.success) {
|
||||
@@ -130,20 +139,24 @@
|
||||
});
|
||||
|
||||
// Secret 재발급
|
||||
document.getElementById('btnRegenSecret').addEventListener('click', function () {
|
||||
openModal('Secret 재발급', '재발급 시 기존 Secret은 무효화됩니다. 계속하려면 비밀번호를 입력하세요.', function (pw) {
|
||||
var btnRegen = document.getElementById('btnRegenSecret');
|
||||
if (btnRegen) btnRegen.addEventListener('click', function () {
|
||||
openModal('Secret 재발급',
|
||||
'재발급 시 기존 Secret은 즉시 무효화됩니다. 새 Secret을 수신 서버의 Webhook 서명검증에 반영하지 않으면 이후 발송되는 모든 Webhook의 서명 검증이 실패합니다. 계속하려면 비밀번호를 입력하세요.',
|
||||
function (pw) {
|
||||
post('/webhook/regenerate-secret', pw).then(function (res) {
|
||||
if (res.success) {
|
||||
document.getElementById('secretMasked').textContent = res.secret;
|
||||
closeModal();
|
||||
alert('Secret이 재발급되었습니다. 새 값을 안전하게 보관하세요.');
|
||||
alert('Secret이 재발급되었습니다.\n반드시 수신 서버의 서명검증 Secret을 새 값으로 교체하세요.\n교체 전까지 Webhook 서명 검증이 실패합니다.');
|
||||
} else { showError(res.message || '실패했습니다.'); }
|
||||
}).catch(function () { showError('요청 처리 중 오류가 발생했습니다.'); });
|
||||
});
|
||||
});
|
||||
|
||||
// 삭제
|
||||
document.getElementById('btnDeleteWebhook').addEventListener('click', function () {
|
||||
var btnDelete = document.getElementById('btnDeleteWebhook');
|
||||
if (btnDelete) btnDelete.addEventListener('click', function () {
|
||||
openModal('Webhook 삭제', '삭제하면 복구할 수 없습니다. 계속하려면 비밀번호를 입력하세요.', function (pw) {
|
||||
post('/webhook/delete', pw).then(function (res) {
|
||||
if (res.success) { window.location.href = '/webhook'; }
|
||||
|
||||
@@ -1,54 +1,79 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
|
||||
xmlns:sec="http://www.thymeleaf.org/extras/spring-security"
|
||||
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/djbank_title_layout}">
|
||||
|
||||
<body>
|
||||
<section layout:fragment="title">
|
||||
<div class="page-title-banner">
|
||||
<img th:src="@{/img/img_title_bg.png}" class="title-image">
|
||||
<h1>Webhook 수정</h1>
|
||||
<h1>Webhook 관리</h1>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<th:block layout:fragment="contentFragment">
|
||||
<section class="webhook-container webhook-register">
|
||||
<section class="apikey-register-container">
|
||||
|
||||
<div class="webhook-steps" th:with="cur=${currentStep}">
|
||||
<div class="webhook-step" th:classappend="${cur >= 1} ? 'active'">
|
||||
<span class="step-no">1</span><span class="step-label">기본 정보</span>
|
||||
<!-- Title Bar -->
|
||||
<div class="common-title-bar">
|
||||
<h2 class="common-title">Webhook 수정</h2>
|
||||
<span class="common-subtitle">알림 받을 API 구성을 변경해주세요.</span>
|
||||
</div>
|
||||
<div class="webhook-step" th:classappend="${cur >= 2} ? 'active'">
|
||||
<span class="step-no">2</span><span class="step-label">API 선택</span>
|
||||
|
||||
<!-- Progress Indicator -->
|
||||
<div class="register-progress">
|
||||
<div class="progress-steps">
|
||||
<div class="progress-step-item">
|
||||
<div class="step-icon-wrapper">
|
||||
<div class="step-icon">
|
||||
<img th:src="@{/img/apikey_step1.png}" alt="기본 정보" width="36" height="36">
|
||||
</div>
|
||||
</div>
|
||||
<div class="step-label">기본 정보</div>
|
||||
</div>
|
||||
|
||||
<div class="step-dots">
|
||||
<span></span><span></span><span></span>
|
||||
</div>
|
||||
|
||||
<div class="progress-step-item active">
|
||||
<div class="step-icon-wrapper">
|
||||
<div class="step-icon">
|
||||
<img th:src="@{/img/apikey_step2.png}" alt="API 선택" width="36" height="36">
|
||||
</div>
|
||||
</div>
|
||||
<div class="step-label">API 선택</div>
|
||||
</div>
|
||||
|
||||
<div class="step-dots">
|
||||
<span></span><span></span><span></span>
|
||||
</div>
|
||||
|
||||
<div class="progress-step-item">
|
||||
<div class="step-icon-wrapper">
|
||||
<div class="step-icon">
|
||||
<img th:src="@{/img/apikey_step3.png}" alt="수정 완료" width="36" height="36">
|
||||
</div>
|
||||
</div>
|
||||
<div class="step-label">수정 완료</div>
|
||||
</div>
|
||||
<div class="webhook-step" th:classappend="${cur >= 3} ? 'active'">
|
||||
<span class="step-no">3</span><span class="step-label">완료</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="webhook-field-error" th:if="${error}" th:text="${error}"></p>
|
||||
|
||||
<form id="modifyStep2Form" method="post" th:action="@{/webhook/modify/step2}">
|
||||
<p class="field-help">이 Webhook으로 상태 알림을 받을 API를 선택하세요. (1개 이상)</p>
|
||||
<!-- API 선택 공용 모듈 -->
|
||||
<th:block th:replace="~{fragment/api_selector :: apiSelector(${apiServices}, ${webhookModification.selectedApis}, '/webhook/modify/step2', '/webhook/modify/step2/save')}"/>
|
||||
|
||||
<div class="webhook-api-groups">
|
||||
<div class="webhook-api-group" th:each="group : ${apiGroups}">
|
||||
<h3 class="group-name" th:text="${group.groupName}">그룹명</h3>
|
||||
<div class="webhook-api-list">
|
||||
<label class="webhook-api-card" th:each="api : ${group.apiGroupApiList}">
|
||||
<input type="checkbox" name="selectedApis" th:value="${api.apiId}"
|
||||
th:checked="${selectedApis != null and #lists.contains(selectedApis, api.apiId)}">
|
||||
<span class="api-name" th:text="${api.apiName}">API 이름</span>
|
||||
<span class="api-id" th:text="${api.apiId}">API ID</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Form Actions -->
|
||||
<div class="form-actions">
|
||||
<button type="button" id="btnPrevStep" class="btn-submit btn-secondary">
|
||||
이전
|
||||
</button>
|
||||
<button type="submit" form="apiSelectorForm" class="btn-submit btn-primary">
|
||||
수정 완료
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="webhook-actions">
|
||||
<a class="btn-webhook-cancel" th:href="@{/webhook/modify/step1}">이전</a>
|
||||
<button type="submit" class="btn-webhook-next">수정 완료</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</th:block>
|
||||
</body>
|
||||
|
||||
@@ -1,55 +1,79 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
|
||||
xmlns:sec="http://www.thymeleaf.org/extras/spring-security"
|
||||
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/djbank_title_layout}">
|
||||
|
||||
<body>
|
||||
<section layout:fragment="title">
|
||||
<div class="page-title-banner">
|
||||
<img th:src="@{/img/img_title_bg.png}" class="title-image">
|
||||
<h1>Webhook 신청</h1>
|
||||
<h1>Webhook 관리</h1>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<th:block layout:fragment="contentFragment">
|
||||
<section class="webhook-container webhook-register">
|
||||
<section class="apikey-register-container">
|
||||
|
||||
<div class="webhook-steps" th:with="cur=${currentStep}">
|
||||
<div class="webhook-step" th:classappend="${cur >= 1} ? 'active'">
|
||||
<span class="step-no">1</span><span class="step-label">기본 정보</span>
|
||||
<!-- Title Bar -->
|
||||
<div class="common-title-bar">
|
||||
<h2 class="common-title">Webhook 신청</h2>
|
||||
<span class="common-subtitle">상태 알림을 받을 API를 선택해주세요.</span>
|
||||
</div>
|
||||
<div class="webhook-step" th:classappend="${cur >= 2} ? 'active'">
|
||||
<span class="step-no">2</span><span class="step-label">API 선택</span>
|
||||
|
||||
<!-- Progress Indicator -->
|
||||
<div class="register-progress">
|
||||
<div class="progress-steps">
|
||||
<div class="progress-step-item">
|
||||
<div class="step-icon-wrapper">
|
||||
<div class="step-icon">
|
||||
<img th:src="@{/img/apikey_step1.png}" alt="기본 정보" width="36" height="36">
|
||||
</div>
|
||||
</div>
|
||||
<div class="step-label">기본 정보</div>
|
||||
</div>
|
||||
|
||||
<div class="step-dots">
|
||||
<span></span><span></span><span></span>
|
||||
</div>
|
||||
|
||||
<div class="progress-step-item active">
|
||||
<div class="step-icon-wrapper">
|
||||
<div class="step-icon">
|
||||
<img th:src="@{/img/apikey_step2.png}" alt="API 선택" width="36" height="36">
|
||||
</div>
|
||||
</div>
|
||||
<div class="step-label">API 선택</div>
|
||||
</div>
|
||||
|
||||
<div class="step-dots">
|
||||
<span></span><span></span><span></span>
|
||||
</div>
|
||||
|
||||
<div class="progress-step-item">
|
||||
<div class="step-icon-wrapper">
|
||||
<div class="step-icon">
|
||||
<img th:src="@{/img/apikey_step3.png}" alt="신청 완료" width="36" height="36">
|
||||
</div>
|
||||
</div>
|
||||
<div class="step-label">신청 완료</div>
|
||||
</div>
|
||||
<div class="webhook-step" th:classappend="${cur >= 3} ? 'active'">
|
||||
<span class="step-no">3</span><span class="step-label">완료</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="webhook-field-error" th:if="${error}" th:text="${error}"></p>
|
||||
|
||||
<form id="registerStep2Form" method="post" th:action="@{/webhook/register/step2}">
|
||||
<p class="field-help">이 Webhook으로 상태 알림을 받을 API를 선택하세요. (1개 이상)</p>
|
||||
<!-- API 선택 공용 모듈 -->
|
||||
<th:block th:replace="~{fragment/api_selector :: apiSelector(${apiServices}, ${webhookRegistration.selectedApis}, '/webhook/register/step2', '/webhook/register/step2/save')}"/>
|
||||
|
||||
<div class="webhook-api-groups">
|
||||
<div class="webhook-api-group" th:each="group : ${apiGroups}">
|
||||
<h3 class="group-name" th:text="${group.groupName}">그룹명</h3>
|
||||
<div class="webhook-api-list">
|
||||
<label class="webhook-api-card"
|
||||
th:each="api : ${group.apiGroupApiList}">
|
||||
<input type="checkbox" name="selectedApis" th:value="${api.apiId}"
|
||||
th:checked="${selectedApis != null and #lists.contains(selectedApis, api.apiId)}">
|
||||
<span class="api-name" th:text="${api.apiName}">API 이름</span>
|
||||
<span class="api-id" th:text="${api.apiId}">API ID</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Form Actions -->
|
||||
<div class="form-actions">
|
||||
<button type="button" id="btnPrevStep" class="btn-submit btn-secondary">
|
||||
이전
|
||||
</button>
|
||||
<button type="submit" form="apiSelectorForm" class="btn-submit btn-primary">
|
||||
신청 완료
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="webhook-actions">
|
||||
<a class="btn-webhook-cancel" th:href="@{/webhook/register/step1}">이전</a>
|
||||
<button type="submit" class="btn-webhook-next">신청 완료</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</th:block>
|
||||
</body>
|
||||
|
||||
@@ -6,35 +6,63 @@
|
||||
<section layout:fragment="title">
|
||||
<div class="page-title-banner">
|
||||
<img th:src="@{/img/img_title_bg.png}" class="title-image">
|
||||
<h1>Webhook 신청 완료</h1>
|
||||
<h1>Webhook 관리</h1>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<th:block layout:fragment="contentFragment">
|
||||
<section class="webhook-container webhook-register">
|
||||
<section class="apikey-register-container">
|
||||
|
||||
<div class="webhook-steps">
|
||||
<div class="webhook-step active"><span class="step-no">1</span><span class="step-label">기본 정보</span></div>
|
||||
<div class="webhook-step active"><span class="step-no">2</span><span class="step-label">API 선택</span></div>
|
||||
<div class="webhook-step active"><span class="step-no">3</span><span class="step-label">완료</span></div>
|
||||
<!-- Title Bar -->
|
||||
<div class="common-title-bar">
|
||||
<h2 class="common-title">Webhook 신청</h2>
|
||||
<span class="common-subtitle">Webhook 등록이 완료되었습니다.</span>
|
||||
</div>
|
||||
|
||||
<!-- Progress Indicator -->
|
||||
<div class="register-progress">
|
||||
<div class="progress-steps">
|
||||
<div class="progress-step-item">
|
||||
<div class="step-icon-wrapper">
|
||||
<div class="step-icon">
|
||||
<img th:src="@{/img/apikey_step1.png}" alt="기본 정보" width="36" height="36">
|
||||
</div>
|
||||
</div>
|
||||
<div class="step-label">기본 정보</div>
|
||||
</div>
|
||||
|
||||
<div class="step-dots">
|
||||
<span></span><span></span><span></span>
|
||||
</div>
|
||||
|
||||
<div class="progress-step-item">
|
||||
<div class="step-icon-wrapper">
|
||||
<div class="step-icon">
|
||||
<img th:src="@{/img/apikey_step2.png}" alt="API 선택" width="36" height="36">
|
||||
</div>
|
||||
</div>
|
||||
<div class="step-label">API 선택</div>
|
||||
</div>
|
||||
|
||||
<div class="step-dots">
|
||||
<span></span><span></span><span></span>
|
||||
</div>
|
||||
|
||||
<div class="progress-step-item active">
|
||||
<div class="step-icon-wrapper">
|
||||
<div class="step-icon">
|
||||
<img th:src="@{/img/apikey_step3.png}" alt="신청 완료" width="36" height="36">
|
||||
</div>
|
||||
</div>
|
||||
<div class="step-label">신청 완료</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="webhook-complete">
|
||||
<div class="complete-icon">✅</div>
|
||||
<h3>Webhook이 등록되었습니다</h3>
|
||||
|
||||
<div class="webhook-secret-box">
|
||||
<div class="secret-warning">
|
||||
<i class="fas fa-exclamation-triangle"></i>
|
||||
아래 Secret Key는 <strong>지금 한 번만</strong> 전체가 표시됩니다. 안전한 곳에 보관하세요.
|
||||
(분실 시 재발급 필요)
|
||||
</div>
|
||||
<label>Secret Key</label>
|
||||
<div class="secret-value-row">
|
||||
<input type="text" id="secretValue" th:value="${secret}" readonly>
|
||||
<button type="button" class="btn-copy" id="copySecret">복사</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="field-help">Secret Key는 [Webhook 관리]에서 비밀번호 확인 후 조회할 수 있습니다.</p>
|
||||
|
||||
<div class="webhook-actions center">
|
||||
<a class="btn-webhook-next" th:href="@{/webhook}">완료</a>
|
||||
@@ -42,26 +70,5 @@
|
||||
</div>
|
||||
</section>
|
||||
</th:block>
|
||||
|
||||
<th:block layout:fragment="contentScript">
|
||||
<script th:inline="javascript">
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
var btn = document.getElementById('copySecret');
|
||||
var input = document.getElementById('secretValue');
|
||||
if (btn && input) {
|
||||
btn.addEventListener('click', function () {
|
||||
input.select();
|
||||
input.setSelectionRange(0, 99999);
|
||||
navigator.clipboard.writeText(input.value).then(function () {
|
||||
btn.textContent = '복사됨';
|
||||
setTimeout(function () { btn.textContent = '복사'; }, 1500);
|
||||
}).catch(function () {
|
||||
document.execCommand('copy');
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</th:block>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
|
||||
<body>
|
||||
<!--
|
||||
API 선택 공용 모듈 (사이드바 카테고리 + 검색 + AJAX 카드그리드 + 플로팅 카트 + 선택목록 모달)
|
||||
|
||||
사용처: 앱(API Key) 신청/수정 step2, Webhook 신청/수정 step2
|
||||
파라미터:
|
||||
apiServices : List<ApiServiceDTO> — 사이드바 카테고리 (컨트롤러 model "apiServices")
|
||||
selectedApis: List<String> — 세션에 보존된 기선택 API ID 목록
|
||||
formAction : String — 제출(POST) 경로 (예: '/webhook/register/step2')
|
||||
saveAction : String — "이전" 버튼 저장(POST) 경로 (예: '/webhook/register/step2/save')
|
||||
|
||||
호출 예:
|
||||
<th:block th:replace="~{fragment/api_selector :: apiSelector(${apiServices}, ${webhookRegistration.selectedApis}, '/webhook/register/step2', '/webhook/register/step2/save')}"/>
|
||||
|
||||
주의:
|
||||
- 폼 id는 고정 "apiSelectorForm" — 제출 버튼은 form="apiSelectorForm" 으로 연결.
|
||||
- "이전" 버튼은 호출 페이지에 id="btnPrevStep" 으로 두면 모듈 JS가 saveAction 으로 저장 후 이동.
|
||||
- 추가 hidden 필드가 필요하면 호출 페이지에서 form="apiSelectorForm" 속성으로 주입.
|
||||
- API 목록은 GET /apis/for_request (ROLE_API_KEY_REQUEST) AJAX 로 로드.
|
||||
- 스타일은 기존 _apikey-register.scss (.register-form-container / .api-selection-card-grid / .modal-dialog .api-pill) 재사용.
|
||||
-->
|
||||
<th:block th:fragment="apiSelector(apiServices, selectedApis, formAction, saveAction)">
|
||||
|
||||
<!-- Form Content with Sidebar Layout -->
|
||||
<div class="register-form-container with-sidebar">
|
||||
|
||||
<!-- Sidebar Navigation -->
|
||||
<aside class="apikey-register-sidebar" id="apiSidebar">
|
||||
<nav class="apikey-sidebar-nav">
|
||||
<div class="sidebar-title">API 서비스 목록</div>
|
||||
|
||||
<!-- All APIs -->
|
||||
<div class="menu-section">
|
||||
<div class="menu-title active" data-group="">
|
||||
<span class="menu-text">전체</span>
|
||||
<span class="api-count"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Service Categories -->
|
||||
<div class="menu-section" th:each="service : ${apiServices}">
|
||||
<div class="menu-title" th:data-group="${service.id}">
|
||||
<span class="menu-text" th:text="${service.groupName}">서비스명</span>
|
||||
<span class="api-count"></span>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<!-- Mobile Menu Toggle -->
|
||||
<button class="apikey-mobile-toggle" id="mobileToggle" aria-label="메뉴 열기" type="button">
|
||||
☰
|
||||
</button>
|
||||
|
||||
<!-- Mobile Overlay -->
|
||||
<div class="apikey-mobile-overlay" id="mobileOverlay"></div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="apikey-register-content">
|
||||
<form id="apiSelectorForm" method="post" th:action="@{${formAction}}" class="register-form"
|
||||
th:attr="data-save-action=@{${saveAction}}">
|
||||
|
||||
<!-- Search Box with Select All -->
|
||||
<div class="api-filter-header">
|
||||
<div class="select-all-wrapper" id="selectAllWrapper" style="display: none;">
|
||||
<label class="select-all-label">
|
||||
<input type="checkbox" id="selectAllCheckbox" class="select-all-checkbox visually-hidden">
|
||||
<span class="custom-checkbox"></span>
|
||||
<span id="selectAllText">전체 선택</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="api-search-box">
|
||||
<input type="text"
|
||||
id="apiSearch"
|
||||
class="search-input"
|
||||
placeholder="API 검색...">
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M9.23047 0C14.3285 0 18.4618 4.0539 18.4619 9.05469C18.4619 11.1083 17.7634 13.0014 16.5889 14.5205C16.5913 14.5229 16.5942 14.5249 16.5967 14.5273L19.5498 17.4238C20.1502 18.0131 20.1501 18.9683 19.5498 19.5576C18.949 20.147 17.9748 20.147 17.374 19.5576L14.4209 16.6621C14.3966 16.6383 14.3749 16.6119 14.3525 16.5869C12.8868 17.5478 11.1257 18.1094 9.23047 18.1094C4.13258 18.1092 0 14.0554 0 9.05469C0.000110268 4.05404 4.13265 0.000222701 9.23047 0ZM9.23047 3.01855C5.83201 3.01878 3.07726 5.721 3.07715 9.05469C3.07715 12.3885 5.83194 15.0916 9.23047 15.0918C12.6292 15.0918 15.3848 12.3886 15.3848 9.05469C15.3847 5.72086 12.6291 3.01855 9.23047 3.01855Z" fill="#515961"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- API Cards Grid -->
|
||||
<div class="api-selection-card-grid" id="apiCardGrid">
|
||||
<div class="loading-state" id="loadingState" style="grid-column: 1 / -1;">
|
||||
<div class="loading-spinner"></div>
|
||||
<p>API 목록을 불러오는 중...</p>
|
||||
</div>
|
||||
|
||||
<div class="empty-api-state" id="emptyState" style="display: none; grid-column: 1 / -1;">
|
||||
<div class="empty-icon">📦</div>
|
||||
<h3>API를 선택해주세요</h3>
|
||||
<p>왼쪽 메뉴에서 서비스를 선택하면 해당 API 목록이 표시됩니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</main>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Floating Cart Button -->
|
||||
<button type="button" class="floating-cart-btn" id="floatingCartBtn" style="display: none;">
|
||||
<span class="cart-label">선택된 API</span>
|
||||
<span class="cart-badge" id="cartBadge">
|
||||
<span class="cart-count">0</span>
|
||||
<span class="cart-unit">개</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<!-- Selected APIs Modal -->
|
||||
<div class="modal" id="selectedApisModal" style="display: none;">
|
||||
<div class="modal-backdrop" id="modalOverlay"></div>
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title">선택된 API 목록</h3>
|
||||
<button type="button" class="modal-close" id="modalCloseBtn">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="selected-apis-list" id="modalSelectedList"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" id="modalCancelBtn">닫기</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 모듈 초기 데이터 + 스크립트 -->
|
||||
<script th:inline="javascript">
|
||||
window.API_SELECTOR_SELECTED = /*[[${selectedApis}]]*/ [];
|
||||
</script>
|
||||
<script th:src="@{/js/api-selector.js}"></script>
|
||||
</th:block>
|
||||
</body>
|
||||
</html>
|
||||
@@ -27,6 +27,7 @@
|
||||
<li><a th:href="@{/service/intro}">API 포탈 소개</a></li>
|
||||
<li><a th:href="@{/service/guide}">회원가입 안내</a></li>
|
||||
<li><a th:href="@{/service/oauth2-guide}">OAuth2 개발가이드</a></li>
|
||||
<li><a th:href="@{/service/webhook-dev-guide}">웹훅 개발가이드</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><a href="/apis" class="nav-link">오픈 API</a></li>
|
||||
@@ -180,6 +181,7 @@
|
||||
<li><a th:href="@{/service/intro}">API 포탈 소개</a></li>
|
||||
<li><a th:href="@{/service/guide}">회원가입 안내</a></li>
|
||||
<li><a th:href="@{/service/oauth2-guide}">OAuth2 개발가이드</a></li>
|
||||
<li><a th:href="@{/service/webhook-dev-guide}">웹훅 개발가이드</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user