사용자 Secret 관리 기능 추가:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled

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