20 Commits

Author SHA1 Message Date
Rinjae 41391c8df0 - Webhook 신청 및 관리 UI 전면 개편 - 신규 가이드 반영
- Webhook 수정/삭제 기능 UX 개선 - 단계별 디자인 업데이트
- Webhook 등록/수정 CSS 및 JS 로직 최적화 및 재사용 코드 추가
2026-07-20 19:53:54 +09:00
Rinjae 9f4874fe44 Merge branch 'feats/api-status' into design
# Conflicts:
#	src/main/resources/static/css/main.css.map
#	src/main/resources/static/css/main.min.css
#	src/main/resources/static/css/main.min.css.map
#	src/main/resources/templates/views/apps/mypage/apiKeyModifyStep2.html
#	src/main/resources/templates/views/apps/mypage/apiKeyRegisterStep2.html
#	src/main/resources/templates/views/fragment/djbank/header_container.html
2026-07-20 19:13:38 +09:00
Rinjae 43d915342e 모니터링 코드 엔티티 제거 및 API 선택 모듈 추가:
- MonitoringCode 엔티티, 리포지토리, 복합키 클래스 삭제
- API 선택 공용 모듈 JS 및 HTML 파일 추가 (api_selector)
- 웹훅 개발 가이드 페이지 신규 추가 (webhook-dev-guide)
2026-07-20 19:10:05 +09:00
Rinjae ba00b80bfe Webhook 신청/관리 및 EventType 제공 기능 추가:
- Webhook 신청/수정 3단계 UI 및 관련 서비스 구현
- TSEAIRM28 공통코드 기반 EventType 조회 기능 추가
- 신규/수정 세션 관리 및 비밀번호 재인증 로직 포함
2026-07-20 17:36:17 +09:00
Rinjae 87763ba325 CSS 메인 파일 업데이트:
- "main.min.css"에서 디자인 스타일 및 애니메이션 수정 및 최적화.
2026-07-20 16:48:12 +09:00
Rinjae 5b7a4a108d design-merge 강제 병합 - 디자인 반영본 전체 통합
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 16:32:18 +09:00
hong 9b9bcb5be4 패딩 수정 2026-07-20 14:05:43 +09:00
Rinjae bdb249b74b Merge branch 'master' into feats/api-status 2026-07-20 10:57:55 +09:00
hong 93f7c210bc 11 2026-07-14 15:52:10 +09:00
hong bb79b55f08 fix: 회원가입 , oauth 반응형 수정 2026-07-14 14:54:53 +09:00
hong 23bd0c1207 fix : 메인페이지 반응형 수정 2026-07-14 10:35:14 +09:00
hong 28a9ff1a7f feat: 마이페이지 퍼블리싱 2026-07-14 10:34:31 +09:00
hong 045f354e5c Q&A 리스트 퍼블리싱 2026-07-09 10:45:20 +09:00
hong 4f577c8c14 Faq , q&a 상세 , 피드백 , 마이페이지 이용자 관리 퍼블리싱 2026-07-09 09:47:16 +09:00
hong cc4c1a6b53 feat : 오픈 API & 공지사항 퍼블리싱 2026-07-06 17:21:58 +09:00
hong 551a2da717 feat: 회원가입 안내 및 oauth 2 페이지 퍼블리싱 2026-07-03 16:41:24 +09:00
hong 1c57d348e6 디자인 시안 적용 및 애니메이션 개선 2026-07-02 17:52:59 +09:00
Rinjae 2c29c8a466 공지사항 목록 정렬 및 UI 개선
- 상단고정(fixYn) 우선 정렬 로직 추가
- 고정 뱃지 UI 템플릿 추가
2026-06-16 14:46:25 +09:00
Rinjae d2052d0e16 Q&A 이벤트 리팩토링
- InquiryCommentCreatedAdminEvent 클래스 제거
- build.gradle 리소스 처리 코드 주석 처리
2026-06-16 10:53:39 +09:00
Rinjae 9c4f3cfb04 공지사항 상세 - 장애/점검 정보 노출 추가
- PortalNoticeDTO 장애/점검 필드 확장
- Facade에서 djb_incident 데이터 결합
- 목록/상세에 유형 뱃지 시작 종료 영향 API 표시

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-16 09:11:28 +09:00
53 changed files with 4065 additions and 1281 deletions
+2 -1
View File
@@ -254,7 +254,8 @@ CREATE TABLE DVPOWN.PT_MESSAGE_RECIPIENT
) )
; ;
create table DVPOWN.PT_TOKEN create table PT_TOKEN
(
( (
TOKEN VARCHAR2(255) not null TOKEN VARCHAR2(255) not null
primary key, primary key,
@@ -22,6 +22,10 @@ public class PortalApplication extends SpringBootServletInitializer {
private static final Logger portal_logger = LoggerFactory.getLogger(PortalApplication.class); private static final Logger portal_logger = LoggerFactory.getLogger(PortalApplication.class);
public PortalApplication() {
super();
}
public static void main(String[] args) { public static void main(String[] args) {
portal_logger.info("##### PortalApplication Start #####"); portal_logger.info("##### PortalApplication Start #####");
@@ -85,9 +85,9 @@ public class BaseDatasourceConfiguration {
persistenceUnit = "gateway"; persistenceUnit = "gateway";
} }
// 개발 환경용 // 개발 환경용 - local 프로파일에서는 스키마 검증(ddl-auto) 해제
if (env.matchesProfiles("local")) { if (env.matchesProfiles("local")) {
properties.put("hibernate.hbm2ddl.auto", "validate"); properties.put("hibernate.hbm2ddl.auto", "none");
} }
@@ -0,0 +1,387 @@
package com.eactive.apim.portal.djb.webhook.controller;
import com.eactive.apim.portal.apps.apiservice.dto.ApiGroupSearch;
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.WebhookDTO;
import com.eactive.apim.portal.djb.webhook.dto.WebhookRegistrationDTO;
import com.eactive.apim.portal.djb.webhook.service.WebhookEventTypeProvider;
import com.eactive.apim.portal.djb.webhook.service.WebhookService;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import javax.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.access.annotation.Secured;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.SessionAttributes;
import org.springframework.web.bind.support.SessionStatus;
import org.springframework.web.servlet.ModelAndView;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
/**
* Webhook 신청/관리 (기업 사용자, ROLE_APP).
*
* App API Key 신청의 3-Step + 세션 + 비밀번호 재인증 패턴을 답습하되, 승인 워크플로우 없이 즉시 발급한다.
* 참조: {@code apps/app/controller/MyAppController}.
*/
@Slf4j
@Controller
@RequestMapping("/webhook")
@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;
private final WebhookService webhookService;
private final WebhookEventTypeProvider eventTypeProvider;
private final ApiServiceService apiServiceService;
private final AppServiceFacade appServiceFacade;
@ModelAttribute("webhookRegistration")
public WebhookRegistrationDTO webhookRegistration() {
return new WebhookRegistrationDTO();
}
@ModelAttribute("webhookModification")
public WebhookRegistrationDTO webhookModification() {
return new WebhookRegistrationDTO();
}
// ============================ 조회 ============================
@Secured("ROLE_APP")
@GetMapping
public ModelAndView index() {
String orgId = currentOrgId();
Optional<WebhookDTO> webhook = webhookService.getByOrg(orgId);
if (webhook.isPresent()) {
ModelAndView mav = new ModelAndView("apps/webhook/webhookList");
mav.addObject("webhook", webhook.get());
return mav;
}
return new ModelAndView("apps/webhook/webhookEmpty");
}
// ======================= 신규 신청 플로우 =======================
@GetMapping("/register/step1")
public ModelAndView registerStep1(
@RequestParam(value = "clear", required = false, defaultValue = "false") boolean clear,
@ModelAttribute("webhookRegistration") WebhookRegistrationDTO registration,
SessionStatus sessionStatus,
Model model) {
if (webhookService.existsByOrg(currentOrgId())) {
return new ModelAndView("redirect:/webhook");
}
if (clear) {
sessionStatus.setComplete();
registration = new WebhookRegistrationDTO();
model.addAttribute("webhookRegistration", registration);
}
registration.setRequestType("NEW");
ModelAndView mav = new ModelAndView("apps/webhook/webhookRegisterStep1");
mav.addObject("eventTypes", eventTypeProvider.getAll());
addStepModel(mav, 1);
return mav;
}
@PostMapping("/register/step1")
public ModelAndView processStep1(
@Valid @ModelAttribute("webhookRegistration") WebhookRegistrationDTO registration,
BindingResult bindingResult,
Model model) {
validateStep1(registration, bindingResult);
if (bindingResult.hasErrors()) {
ModelAndView mav = new ModelAndView("apps/webhook/webhookRegisterStep1");
mav.addObject("eventTypes", eventTypeProvider.getAll());
addStepModel(mav, 1);
return mav;
}
return new ModelAndView("redirect:/webhook/register/step2");
}
@GetMapping("/register/step2")
public ModelAndView registerStep2(
@ModelAttribute("webhookRegistration") WebhookRegistrationDTO registration,
RedirectAttributes redirectAttributes) {
if (!registration.isStep1Complete()) {
return new ModelAndView("redirect:/webhook/register/step1");
}
ModelAndView mav = new ModelAndView("apps/webhook/webhookRegisterStep2");
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,
@ModelAttribute("webhookRegistration") WebhookRegistrationDTO registration,
SessionStatus sessionStatus,
RedirectAttributes redirectAttributes) {
registration.setSelectedApis(selectedApis);
if (!registration.isStep2Complete()) {
redirectAttributes.addFlashAttribute("error", "알림 대상 API를 1개 이상 선택해주세요.");
return new ModelAndView("redirect:/webhook/register/step2");
}
try {
// 평문 Secret 은 화면에 노출하지 않는다 — 목록에서 비밀번호 재인증 후 조회.
webhookService.create(registration, currentOrgId());
sessionStatus.setComplete();
redirectAttributes.addFlashAttribute("registrationSuccess", true);
return new ModelAndView("redirect:/webhook/register/step3");
} catch (RuntimeException e) {
log.warn("Webhook 신청 실패 orgId={} : {}", currentOrgId(), e.getMessage());
redirectAttributes.addFlashAttribute("error", e.getMessage());
return new ModelAndView("redirect:/webhook/register/step2");
}
}
@GetMapping("/register/step3")
public ModelAndView registerStep3(Model model) {
if (!Boolean.TRUE.equals(model.getAttribute("registrationSuccess"))) {
return new ModelAndView("redirect:/webhook");
}
ModelAndView mav = new ModelAndView("apps/webhook/webhookRegisterStep3");
addStepModel(mav, 3);
return mav;
}
@GetMapping("/register/cancel")
public String cancelRegistration(SessionStatus sessionStatus) {
sessionStatus.setComplete();
return "redirect:/webhook";
}
// ========================= 수정 플로우 =========================
@GetMapping("/modify/step1")
public ModelAndView modifyStep1(
@ModelAttribute("webhookModification") WebhookRegistrationDTO modification,
Model model) {
Optional<WebhookDTO> current = webhookService.getByOrg(currentOrgId());
if (!current.isPresent()) {
return new ModelAndView("redirect:/webhook/register/step1?clear=true");
}
WebhookDTO webhook = current.get();
// 세션에 아직 채워지지 않았으면 현재 등록값으로 초기화
if (modification.getId() == null || !webhook.getId().equals(modification.getId())) {
modification.setId(webhook.getId());
modification.setRequestType("MODIFY");
modification.setTargetUrl(webhook.getTargetUrl());
modification.setEventTypes(webhook.getEventTypes().stream()
.map(e -> e.getCode()).collect(java.util.stream.Collectors.toList()));
modification.setSelectedApis(new java.util.ArrayList<>(webhook.getApiIds()));
model.addAttribute("webhookModification", modification);
}
ModelAndView mav = new ModelAndView("apps/webhook/webhookModifyStep1");
mav.addObject("eventTypes", eventTypeProvider.getAll());
addStepModel(mav, 1);
return mav;
}
@PostMapping("/modify/step1")
public ModelAndView processModifyStep1(
@Valid @ModelAttribute("webhookModification") WebhookRegistrationDTO modification,
BindingResult bindingResult,
Model model) {
validateStep1(modification, bindingResult);
if (bindingResult.hasErrors()) {
ModelAndView mav = new ModelAndView("apps/webhook/webhookModifyStep1");
mav.addObject("eventTypes", eventTypeProvider.getAll());
addStepModel(mav, 1);
return mav;
}
return new ModelAndView("redirect:/webhook/modify/step2");
}
@GetMapping("/modify/step2")
public ModelAndView modifyStep2(
@ModelAttribute("webhookModification") WebhookRegistrationDTO modification) {
if (modification.getId() == null || !modification.isStep1Complete()) {
return new ModelAndView("redirect:/webhook/modify/step1");
}
ModelAndView mav = new ModelAndView("apps/webhook/webhookModifyStep2");
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,
@ModelAttribute("webhookModification") WebhookRegistrationDTO modification,
SessionStatus sessionStatus,
RedirectAttributes redirectAttributes) {
modification.setSelectedApis(selectedApis);
if (modification.getId() == null || !modification.isStep2Complete()) {
redirectAttributes.addFlashAttribute("error", "알림 대상 API를 1개 이상 선택해주세요.");
return new ModelAndView("redirect:/webhook/modify/step2");
}
try {
webhookService.update(modification.getId(), modification, currentOrgId());
sessionStatus.setComplete();
redirectAttributes.addFlashAttribute("modifySuccess", true);
return new ModelAndView("redirect:/webhook/modify/step3");
} catch (RuntimeException e) {
log.warn("Webhook 수정 실패 orgId={} : {}", currentOrgId(), e.getMessage());
redirectAttributes.addFlashAttribute("error", e.getMessage());
return new ModelAndView("redirect:/webhook/modify/step2");
}
}
@GetMapping("/modify/step3")
public ModelAndView modifyStep3(Model model) {
if (!Boolean.TRUE.equals(model.getAttribute("modifySuccess"))) {
return new ModelAndView("redirect:/webhook");
}
ModelAndView mav = new ModelAndView("apps/webhook/webhookModifyStep3");
addStepModel(mav, 3);
return mav;
}
@GetMapping("/modify/cancel")
public String cancelModification(SessionStatus sessionStatus) {
sessionStatus.setComplete();
return "redirect:/webhook";
}
// ==================== AJAX (비밀번호 재인증) ====================
@PostMapping("/verify-secret")
@ResponseBody
public Map<String, Object> verifySecret(@RequestParam String password) {
Map<String, Object> result = new HashMap<>();
if (!verifyPassword(password)) {
result.put("success", false);
result.put("message", "비밀번호가 일치하지 않습니다.");
return result;
}
Optional<WebhookDTO> webhook = webhookService.getByOrg(currentOrgId());
if (!webhook.isPresent()) {
result.put("success", false);
result.put("message", "등록된 Webhook이 없습니다.");
return result;
}
result.put("success", true);
result.put("secret", webhookService.getPlainSecret(webhook.get().getId(), currentOrgId()));
return result;
}
@PostMapping("/regenerate-secret")
@ResponseBody
public Map<String, Object> regenerateSecret(@RequestParam String password) {
Map<String, Object> result = new HashMap<>();
if (!verifyPassword(password)) {
result.put("success", false);
result.put("message", "비밀번호가 일치하지 않습니다.");
return result;
}
Optional<WebhookDTO> webhook = webhookService.getByOrg(currentOrgId());
if (!webhook.isPresent()) {
result.put("success", false);
result.put("message", "등록된 Webhook이 없습니다.");
return result;
}
String secret = webhookService.regenerateSecret(webhook.get().getId(), currentOrgId());
result.put("success", true);
result.put("secret", secret);
return result;
}
@PostMapping("/delete")
@ResponseBody
public Map<String, Object> delete(@RequestParam String password) {
Map<String, Object> result = new HashMap<>();
if (!verifyPassword(password)) {
result.put("success", false);
result.put("message", "비밀번호가 일치하지 않습니다.");
return result;
}
Optional<WebhookDTO> webhook = webhookService.getByOrg(currentOrgId());
if (!webhook.isPresent()) {
result.put("success", false);
result.put("message", "등록된 Webhook이 없습니다.");
return result;
}
webhookService.delete(webhook.get().getId(), currentOrgId());
result.put("success", true);
return result;
}
// ============================ helper ============================
private void validateStep1(WebhookRegistrationDTO dto, BindingResult bindingResult) {
String url = dto.getTargetUrl() == null ? "" : dto.getTargetUrl().trim();
if (!bindingResult.hasFieldErrors("targetUrl")
&& !url.startsWith("http://") && !url.startsWith("https://")) {
bindingResult.rejectValue("targetUrl", "invalid.url",
"URL은 http:// 또는 https:// 로 시작해야 합니다.");
}
if (dto.getEventTypes() == null || dto.getEventTypes().isEmpty()) {
bindingResult.rejectValue("eventTypes", "empty.eventTypes",
"EventType을 1개 이상 선택해주세요.");
}
}
private boolean verifyPassword(String password) {
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
return appServiceFacade.verifyUserPassword(user, password);
}
private String currentOrgId() {
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
return user != null && user.getPortalOrg() != null ? user.getPortalOrg().getId() : null;
}
private void addStepModel(ModelAndView mav, int currentStep) {
mav.addObject("currentStep", currentStep);
mav.addObject("totalSteps", TOTAL_STEPS);
}
}
@@ -0,0 +1,15 @@
package com.eactive.apim.portal.djb.webhook.dto;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
/**
* 신규 신청 결과. 평문 {@code secret} 은 발급 직후 1회 노출 목적으로만 전달된다.
*/
@Getter
@RequiredArgsConstructor
public class WebhookCreatedResult {
private final Long id;
private final String secret;
}
@@ -0,0 +1,26 @@
package com.eactive.apim.portal.djb.webhook.dto;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
import lombok.Data;
/**
* 등록된 Webhook 상세 표시용. SECRET 은 마스킹 값만 담고 평문은 별도 재인증 조회로만 노출한다.
*/
@Data
public class WebhookDTO implements Serializable {
private static final long serialVersionUID = 1L;
private Long id;
private String targetUrl;
private String secretMasked;
private String createdDate;
/** 구독 API ID 목록. */
private List<String> apiIds = new ArrayList<>();
/** 구독 EventType(코드+한글명) 목록. */
private List<WebhookEventTypeDTO> eventTypes = new ArrayList<>();
}
@@ -0,0 +1,27 @@
package com.eactive.apim.portal.djb.webhook.dto;
import java.io.Serializable;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* EventType 코드/한글명 쌍. TSEAIRM28(CODEGROUP='EVENT_TYPE') 에서 로드.
* {@code selected} 는 신청 화면에서 현재 구독 여부 표시용.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class WebhookEventTypeDTO implements Serializable {
private static final long serialVersionUID = 1L;
private String code;
private String name;
private boolean selected;
public WebhookEventTypeDTO(String code, String name) {
this.code = code;
this.name = name;
}
}
@@ -0,0 +1,48 @@
package com.eactive.apim.portal.djb.webhook.dto;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
import lombok.Data;
import org.hibernate.validator.constraints.Length;
import org.hibernate.validator.constraints.NotBlank;
/**
* Webhook 신청/수정 스텝 간 세션 보존 데이터 (App 신청 {@code ApiKeyRegistrationDTO} 답습).
*
* Step1: {@code targetUrl} + {@code eventTypes}. Step2: {@code selectedApis}.
*/
@Data
public class WebhookRegistrationDTO implements Serializable {
private static final long serialVersionUID = 1L;
/** 수정 시 대상 신청 ID. 신규 신청이면 null. */
private Long id;
/** "NEW" 또는 "MODIFY" */
private String requestType;
@NotBlank(message = "Webhook 수신 URL을 입력해주세요.")
@Length(max = 255, message = "URL은 255자를 초과할 수 없습니다.")
private String targetUrl;
/** Step1: 구독 EventType 코드 목록 (TSEAIRM28 EVENT_TYPE). */
private List<String> eventTypes = new ArrayList<>();
/** Step2: 알림 대상 API ID 목록. */
private List<String> selectedApis = new ArrayList<>();
public boolean isStep1Complete() {
return targetUrl != null && !targetUrl.trim().isEmpty()
&& eventTypes != null && !eventTypes.isEmpty();
}
public boolean isStep2Complete() {
return selectedApis != null && !selectedApis.isEmpty();
}
public boolean isComplete() {
return isStep1Complete() && isStep2Complete();
}
}
@@ -0,0 +1,13 @@
package com.eactive.apim.portal.djb.webhook.exception;
/**
* Org 당 Webhook 1건 정책 위반(이미 등록됨).
*/
public class WebhookAlreadyExistsException extends RuntimeException {
private static final long serialVersionUID = 1L;
public WebhookAlreadyExistsException(String orgId) {
super("이미 등록된 Webhook이 있습니다. orgId=" + orgId);
}
}
@@ -0,0 +1,13 @@
package com.eactive.apim.portal.djb.webhook.exception;
/**
* 대상 Webhook 신청을 찾을 수 없음.
*/
public class WebhookNotFoundException extends RuntimeException {
private static final long serialVersionUID = 1L;
public WebhookNotFoundException(Long id) {
super("Webhook 신청을 찾을 수 없습니다. id=" + id);
}
}
@@ -0,0 +1,19 @@
package com.eactive.apim.portal.djb.webhook.mapper;
import com.eactive.apim.portal.djb.webhook.dto.WebhookDTO;
import com.eactive.apim.portal.djb.webhook.repository.entity.WebhookRequest;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
/**
* WebhookRequest → WebhookDTO 기본 필드 매핑.
* secretMasked/apiIds/eventTypes 는 연관 테이블 조립이 필요하므로 서비스에서 채운다.
*/
@Mapper(componentModel = "spring")
public interface WebhookMapper {
@Mapping(target = "secretMasked", ignore = true)
@Mapping(target = "apiIds", ignore = true)
@Mapping(target = "eventTypes", ignore = true)
WebhookDTO toDto(WebhookRequest entity);
}
@@ -0,0 +1,20 @@
package com.eactive.apim.portal.djb.webhook.repository;
import com.eactive.apim.portal.djb.webhook.repository.entity.WebhookRequestApi;
import com.eactive.apim.portal.djb.webhook.repository.entity.WebhookRequestApiId;
import com.eactive.eai.rms.data.EMSDataSource;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.transaction.annotation.Transactional;
/**
* PTL_WEBHOOK_REQ_API — 신청별 구독 API 목록.
*/
@EMSDataSource
public interface WebhookRequestApiRepository extends JpaRepository<WebhookRequestApi, WebhookRequestApiId> {
List<WebhookRequestApi> findByWebhookReqId(Long webhookReqId);
@Transactional
void deleteByWebhookReqId(Long webhookReqId);
}
@@ -0,0 +1,20 @@
package com.eactive.apim.portal.djb.webhook.repository;
import com.eactive.apim.portal.djb.webhook.repository.entity.WebhookRequestEvent;
import com.eactive.apim.portal.djb.webhook.repository.entity.WebhookRequestEventId;
import com.eactive.eai.rms.data.EMSDataSource;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.transaction.annotation.Transactional;
/**
* PTL_WEBHOOK_REQ_EVENT — 신청별 구독 EventType 목록.
*/
@EMSDataSource
public interface WebhookRequestEventRepository extends JpaRepository<WebhookRequestEvent, WebhookRequestEventId> {
List<WebhookRequestEvent> findByWebhookReqId(Long webhookReqId);
@Transactional
void deleteByWebhookReqId(Long webhookReqId);
}
@@ -0,0 +1,17 @@
package com.eactive.apim.portal.djb.webhook.repository;
import com.eactive.apim.portal.djb.webhook.repository.entity.WebhookRequest;
import com.eactive.eai.rms.data.EMSDataSource;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
/**
* PTL_WEBHOOK_REQ 조회/저장. Org 당 1건 정책이라 orgId 단건 조회를 제공한다.
*/
@EMSDataSource
public interface WebhookRequestRepository extends JpaRepository<WebhookRequest, Long> {
Optional<WebhookRequest> findByOrgId(String orgId);
boolean existsByOrgId(String orgId);
}
@@ -0,0 +1,66 @@
package com.eactive.apim.portal.djb.webhook.repository.entity;
import com.eactive.apim.portal.common.util.SecurityUtil;
import java.io.Serializable;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.PrePersist;
import javax.persistence.SequenceGenerator;
import javax.persistence.Table;
import lombok.Getter;
import lombok.Setter;
/**
* Webhook 신청 마스터 (EMSAPP.PTL_WEBHOOK_REQ).
*
* admin(eapim-admin) 발송엔진이 이 행을 읽어 TARGET_URL 로 HMAC-SHA256 서명 발송한다.
* SECRET 은 서명 키로 그대로 사용되므로 암호화하지 않고 평문 저장한다.
* CREATED_DATE 는 VARCHAR2(14) yyyyMMddHHmmss 문자열이라 AbstractAuditingEntity 를 쓰지 않고
* {@link #onCreate()} 에서 직접 세팅한다.
*/
@Getter
@Setter
@Entity
@Table(name = "PTL_WEBHOOK_REQ")
public class WebhookRequest implements Serializable {
private static final long serialVersionUID = 1L;
private static final DateTimeFormatter TS = DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "webhookReqSeq")
@SequenceGenerator(name = "webhookReqSeq", sequenceName = "SEQ_PTL_WEBHOOK_REQ_ID", allocationSize = 1)
@Column(name = "ID")
private Long id;
@Column(name = "ORG_ID", length = 36)
private String orgId;
@Column(name = "TARGET_URL", length = 255)
private String targetUrl;
@Column(name = "SECRET", length = 500)
private String secret;
@Column(name = "CREATED_BY", length = 200)
private String createdBy;
@Column(name = "CREATED_DATE", length = 14)
private String createdDate;
@PrePersist
public void onCreate() {
if (createdBy == null) {
createdBy = SecurityUtil.getCurrentLoginId();
}
if (createdDate == null) {
createdDate = LocalDateTime.now().format(TS);
}
}
}
@@ -0,0 +1,62 @@
package com.eactive.apim.portal.djb.webhook.repository.entity;
import com.eactive.apim.portal.common.util.SecurityUtil;
import java.io.Serializable;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Id;
import javax.persistence.IdClass;
import javax.persistence.PrePersist;
import javax.persistence.Table;
import lombok.Getter;
import lombok.Setter;
/**
* Webhook 신청이 알림을 받을 API 목록 (EMSAPP.PTL_WEBHOOK_REQ_API).
* 복합키(WEBHOOK_REQ_ID + API_ID).
*/
@Getter
@Setter
@Entity
@Table(name = "PTL_WEBHOOK_REQ_API")
@IdClass(WebhookRequestApiId.class)
public class WebhookRequestApi implements Serializable {
private static final long serialVersionUID = 1L;
private static final DateTimeFormatter TS = DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
@Id
@Column(name = "WEBHOOK_REQ_ID")
private Long webhookReqId;
@Id
@Column(name = "API_ID", length = 30)
private String apiId;
@Column(name = "CREATED_BY", length = 200)
private String createdBy;
@Column(name = "CREATED_DATE", length = 14)
private String createdDate;
public WebhookRequestApi() {
}
public WebhookRequestApi(Long webhookReqId, String apiId) {
this.webhookReqId = webhookReqId;
this.apiId = apiId;
}
@PrePersist
public void onCreate() {
if (createdBy == null) {
createdBy = SecurityUtil.getCurrentLoginId();
}
if (createdDate == null) {
createdDate = LocalDateTime.now().format(TS);
}
}
}
@@ -0,0 +1,24 @@
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 WebhookRequestApi} 복합키 (WEBHOOK_REQ_ID + API_ID).
*/
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@EqualsAndHashCode
public class WebhookRequestApiId implements Serializable {
private static final long serialVersionUID = 1L;
private Long webhookReqId;
private String apiId;
}
@@ -0,0 +1,63 @@
package com.eactive.apim.portal.djb.webhook.repository.entity;
import com.eactive.apim.portal.common.util.SecurityUtil;
import java.io.Serializable;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Id;
import javax.persistence.IdClass;
import javax.persistence.PrePersist;
import javax.persistence.Table;
import lombok.Getter;
import lombok.Setter;
/**
* Webhook 신청이 구독하는 EventType 목록 (EMSAPP.PTL_WEBHOOK_REQ_EVENT).
* EVENT_TYPE 코드값은 EMSAPP.TSEAIRM28(CODEGROUP='EVENT_TYPE') 과 동일 집합.
* 복합키(WEBHOOK_REQ_ID + EVENT_TYPE).
*/
@Getter
@Setter
@Entity
@Table(name = "PTL_WEBHOOK_REQ_EVENT")
@IdClass(WebhookRequestEventId.class)
public class WebhookRequestEvent implements Serializable {
private static final long serialVersionUID = 1L;
private static final DateTimeFormatter TS = DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
@Id
@Column(name = "WEBHOOK_REQ_ID")
private Long webhookReqId;
@Id
@Column(name = "EVENT_TYPE", length = 36)
private String eventType;
@Column(name = "CREATED_BY", length = 200)
private String createdBy;
@Column(name = "CREATED_DATE", length = 14)
private String createdDate;
public WebhookRequestEvent() {
}
public WebhookRequestEvent(Long webhookReqId, String eventType) {
this.webhookReqId = webhookReqId;
this.eventType = eventType;
}
@PrePersist
public void onCreate() {
if (createdBy == null) {
createdBy = SecurityUtil.getCurrentLoginId();
}
if (createdDate == null) {
createdDate = LocalDateTime.now().format(TS);
}
}
}
@@ -0,0 +1,24 @@
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 WebhookRequestEvent} 복합키 (WEBHOOK_REQ_ID + EVENT_TYPE).
*/
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@EqualsAndHashCode
public class WebhookRequestEventId implements Serializable {
private static final long serialVersionUID = 1L;
private Long webhookReqId;
private String eventType;
}
@@ -0,0 +1,76 @@
package com.eactive.apim.portal.djb.webhook.service;
import com.eactive.apim.portal.djb.webhook.dto.WebhookEventTypeDTO;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import javax.persistence.EntityManager;
import javax.persistence.PersistenceContext;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
/**
* EventType 코드/한글명 제공자. EMSAPP.TSEAIRM28(CODEGROUP='EVENT_TYPE', USEYN='Y') 를 조회한다.
* admin 발송엔진과 동일 공통코드 테이블을 단일 소스로 사용하므로 코드 추가/변경 시 DB 만 수정하면 된다.
*
* TSEAIRM28 은 elink-portal-common 의 {@code MonitoringCode} 엔티티가 이미 매핑하고 있어(같은 테이블 중복 @Entity 금지),
* 여기서는 별도 엔티티 없이 EMS EntityManager 네이티브 쿼리로 필요한 두 컬럼만 조회한다.
*/
@Component
public class WebhookEventTypeProvider {
private static final String EVENT_TYPE_SQL =
"SELECT CODE, CODENAME FROM TSEAIRM28 "
+ "WHERE CODEGROUP = 'EVENT_TYPE' AND USEYN = 'Y' "
+ "ORDER BY SEQ, CODE";
/** EMS 데이터소스가 @Primary 이므로 기본 EntityManager 는 EMS 를 가리킨다. */
@PersistenceContext
private EntityManager entityManager;
@Transactional(readOnly = true)
public List<WebhookEventTypeDTO> getAll() {
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<>();
for (Object[] row : rows()) {
map.put(asString(row[0]), asString(row[1]));
}
return map;
}
@Transactional(readOnly = true)
public boolean isValid(String code) {
if (code == null) {
return false;
}
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();
}
}
@@ -0,0 +1,28 @@
package com.eactive.apim.portal.djb.webhook.service;
import java.security.SecureRandom;
import org.springframework.stereotype.Component;
/**
* Webhook HMAC Secret 생성기.
*
* admin 발송엔진이 이 값을 그대로 HMAC-SHA256 키로 사용하므로(암복호화 없음),
* 128자 영숫자 랜덤 문자열을 평문으로 발급한다(admin 기존 데이터와 동일 형태).
*/
@Component
public class WebhookSecretGenerator {
private static final char[] ALPHANUM =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789".toCharArray();
private static final int LENGTH = 128;
private final SecureRandom random = new SecureRandom();
public String generate() {
StringBuilder sb = new StringBuilder(LENGTH);
for (int i = 0; i < LENGTH; i++) {
sb.append(ALPHANUM[random.nextInt(ALPHANUM.length)]);
}
return sb.toString();
}
}
@@ -0,0 +1,189 @@
package com.eactive.apim.portal.djb.webhook.service;
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.WebhookEventTypeDTO;
import com.eactive.apim.portal.djb.webhook.dto.WebhookRegistrationDTO;
import com.eactive.apim.portal.djb.webhook.exception.WebhookAlreadyExistsException;
import com.eactive.apim.portal.djb.webhook.exception.WebhookNotFoundException;
import com.eactive.apim.portal.djb.webhook.mapper.WebhookMapper;
import com.eactive.apim.portal.djb.webhook.repository.WebhookRequestApiRepository;
import com.eactive.apim.portal.djb.webhook.repository.WebhookRequestEventRepository;
import com.eactive.apim.portal.djb.webhook.repository.WebhookRequestRepository;
import com.eactive.apim.portal.djb.webhook.repository.entity.WebhookRequest;
import com.eactive.apim.portal.djb.webhook.repository.entity.WebhookRequestApi;
import com.eactive.apim.portal.djb.webhook.repository.entity.WebhookRequestEvent;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* Webhook 신청/조회/수정/삭제 및 Secret 발급 오케스트레이션.
*
* 단일 EMS 데이터소스만 사용하므로 기본 {@code @Transactional} 로 충분하다(JTA 분산 트랜잭션 불필요).
* 모든 수정/삭제/Secret 조회는 소유 Org 검증({@link #loadOwned})을 거친다.
*/
@Slf4j
@Service
@Transactional
@RequiredArgsConstructor
public class WebhookService {
private static final String SECRET_MASK = "••••••••••••";
private final WebhookRequestRepository requestRepository;
private final WebhookRequestApiRepository apiRepository;
private final WebhookRequestEventRepository eventRepository;
private final WebhookSecretGenerator secretGenerator;
private final WebhookEventTypeProvider eventTypeProvider;
private final WebhookMapper webhookMapper;
@Transactional(readOnly = true)
public boolean existsByOrg(String orgId) {
return requestRepository.existsByOrgId(orgId);
}
@Transactional(readOnly = true)
public Optional<WebhookDTO> getByOrg(String orgId) {
return requestRepository.findByOrgId(orgId).map(this::toDetailDto);
}
/**
* 신규 신청. Org 당 1건 정책 위반 시 예외. 평문 Secret 을 1회 반환한다.
*/
public WebhookCreatedResult create(WebhookRegistrationDTO dto, String orgId) {
if (requestRepository.existsByOrgId(orgId)) {
throw new WebhookAlreadyExistsException(orgId);
}
validate(dto);
String secret = secretGenerator.generate();
WebhookRequest request = new WebhookRequest();
request.setOrgId(orgId);
request.setTargetUrl(dto.getTargetUrl().trim());
request.setSecret(secret);
WebhookRequest saved = requestRepository.save(request);
persistChildren(saved.getId(), dto);
log.info("Webhook 신규 등록 orgId={} id={}", orgId, saved.getId());
return new WebhookCreatedResult(saved.getId(), secret);
}
/**
* URL/API/EventType 수정. Secret 은 보존한다. 연관 테이블은 delete-all 후 재삽입.
*/
public WebhookDTO update(Long id, WebhookRegistrationDTO dto, String orgId) {
WebhookRequest request = loadOwned(id, orgId);
validate(dto);
request.setTargetUrl(dto.getTargetUrl().trim());
requestRepository.save(request);
apiRepository.deleteByWebhookReqId(id);
eventRepository.deleteByWebhookReqId(id);
apiRepository.flush();
eventRepository.flush();
persistChildren(id, dto);
log.info("Webhook 수정 orgId={} id={}", orgId, id);
return toDetailDto(request);
}
/**
* Secret 재발급(교체). 새 평문 Secret 반환.
*/
public String regenerateSecret(Long id, String orgId) {
WebhookRequest request = loadOwned(id, orgId);
String secret = secretGenerator.generate();
request.setSecret(secret);
requestRepository.save(request);
log.info("Webhook Secret 재발급 orgId={} id={}", orgId, id);
return secret;
}
/**
* 3개 테이블 HardDelete.
*/
public void delete(Long id, String orgId) {
WebhookRequest request = loadOwned(id, orgId);
apiRepository.deleteByWebhookReqId(id);
eventRepository.deleteByWebhookReqId(id);
requestRepository.delete(request);
log.info("Webhook 삭제 orgId={} id={}", orgId, id);
}
/**
* 평문 Secret 조회. 컨트롤러에서 비밀번호 재인증 후에만 호출한다.
*/
@Transactional(readOnly = true)
public String getPlainSecret(Long id, String orgId) {
return loadOwned(id, orgId).getSecret();
}
// ----------------------------------------------------------------
private WebhookRequest loadOwned(Long id, String orgId) {
WebhookRequest request = requestRepository.findById(id)
.orElseThrow(() -> new WebhookNotFoundException(id));
if (!Objects.equals(request.getOrgId(), orgId)) {
throw new AccessDeniedException("해당 Webhook에 대한 권한이 없습니다.");
}
return request;
}
private void persistChildren(Long reqId, WebhookRegistrationDTO dto) {
for (String apiId : dedup(dto.getSelectedApis())) {
apiRepository.save(new WebhookRequestApi(reqId, apiId));
}
for (String eventType : dedup(dto.getEventTypes())) {
if (!eventTypeProvider.isValid(eventType)) {
throw new IllegalArgumentException("유효하지 않은 EventType 코드입니다: " + eventType);
}
eventRepository.save(new WebhookRequestEvent(reqId, eventType));
}
}
private void validate(WebhookRegistrationDTO dto) {
String url = dto.getTargetUrl() == null ? "" : dto.getTargetUrl().trim();
if (!url.startsWith("http://") && !url.startsWith("https://")) {
throw new IllegalArgumentException("URL은 http:// 또는 https:// 로 시작해야 합니다.");
}
if (dto.getEventTypes() == null || dto.getEventTypes().isEmpty()) {
throw new IllegalArgumentException("EventType을 1개 이상 선택해주세요.");
}
if (dto.getSelectedApis() == null || dto.getSelectedApis().isEmpty()) {
throw new IllegalArgumentException("알림 대상 API를 1개 이상 선택해주세요.");
}
}
private List<String> dedup(List<String> values) {
if (values == null) {
return java.util.Collections.emptyList();
}
return new java.util.ArrayList<>(new LinkedHashSet<>(values));
}
private WebhookDTO toDetailDto(WebhookRequest request) {
WebhookDTO dto = webhookMapper.toDto(request);
dto.setSecretMasked(request.getSecret() == null ? "" : SECRET_MASK);
dto.setApiIds(apiRepository.findByWebhookReqId(request.getId()).stream()
.map(WebhookRequestApi::getApiId)
.collect(Collectors.toList()));
Map<String, String> names = eventTypeProvider.asMap();
dto.setEventTypes(eventRepository.findByWebhookReqId(request.getId()).stream()
.map(e -> new WebhookEventTypeDTO(e.getEventType(),
names.getOrDefault(e.getEventType(), e.getEventType())))
.collect(Collectors.toList()));
return dto;
}
}
+30
View File
@@ -30,6 +30,8 @@ spring:
default-page-size: '10' default-page-size: '10'
jpa: jpa:
open-in-view: false open-in-view: false
hibernate:
ddl-auto: none
web: web:
resources: resources:
@@ -203,6 +205,10 @@ portal:
method: GET method: GET
view-name: apps/service/oauth2-guide view-name: apps/service/oauth2-guide
- path-pattern: /service/webhook-dev-guide
method: GET
view-name: apps/service/webhook-dev-guide
- path-pattern: /dashboard - path-pattern: /dashboard
method: GET method: GET
view-name: apps/mypage/dashboard view-name: apps/mypage/dashboard
@@ -295,6 +301,9 @@ page:
oauth2_guide: oauth2_guide:
name: "OAuth2 개발가이드" name: "OAuth2 개발가이드"
path: "/service/oauth2-guide" path: "/service/oauth2-guide"
webhook_dev_guide:
name: "웹훅 개발가이드"
path: "/service/webhook-dev-guide"
apis: apis:
name: "API" name: "API"
path: "#" path: "#"
@@ -388,6 +397,27 @@ page:
api_statistics: api_statistics:
name: "이용 통계" name: "이용 통계"
path: "/statistics/api" path: "/statistics/api"
webhook:
name: "Webhook 관리"
path: "/webhook"
webhook_register_step1:
name: "Webhook 신청 (기본 정보)"
path: "/webhook/register/step1"
webhook_register_step2:
name: "Webhook 신청 (API 선택)"
path: "/webhook/register/step2"
webhook_register_step3:
name: "Webhook 신청 완료"
path: "/webhook/register/step3"
webhook_modify_step1:
name: "Webhook 수정 (기본 정보)"
path: "/webhook/modify/step1"
webhook_modify_step2:
name: "Webhook 수정 (API 선택)"
path: "/webhook/modify/step2"
webhook_modify_step3:
name: "Webhook 수정 완료"
path: "/webhook/modify/step3"
# 에디터 이미지 설정 (약관 이미지 표시용) # 에디터 이미지 설정 (약관 이미지 표시용)
editor: editor:
+547 -43
View File
@@ -1368,7 +1368,7 @@ body.design-survey-active .global-header {
border: none; border: none;
cursor: pointer; cursor: pointer;
padding: 8px; padding: 8px;
z-index: 501; z-index: 1051;
} }
.btn-mobilemenu span { .btn-mobilemenu span {
display: block; display: block;
@@ -1413,7 +1413,7 @@ body.design-survey-active .global-header {
right: 0; right: 0;
bottom: 0; bottom: 0;
background: #FFFFFF; background: #FFFFFF;
z-index: 500; z-index: 1050;
right: -100%; right: -100%;
transition: none; transition: none;
overflow-y: auto; overflow-y: auto;
@@ -4844,7 +4844,7 @@ select.form-control {
right: 0; right: 0;
bottom: 0; bottom: 0;
background: #385670; background: #385670;
z-index: 400; z-index: 1040;
opacity: 0.9; opacity: 0.9;
visibility: hidden; visibility: hidden;
transition: all 0.3s ease; transition: all 0.3s ease;
@@ -4860,7 +4860,7 @@ select.form-control {
left: 0; left: 0;
width: 100%; width: 100%;
height: 100vh; height: 100vh;
z-index: 500; z-index: 1050;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -4920,7 +4920,7 @@ select.form-control {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
z-index: 500; z-index: 1050;
transition: all 0.3s ease; transition: all 0.3s ease;
box-shadow: 0 8px 24px rgba(75, 155, 255, 0.15); box-shadow: 0 8px 24px rgba(75, 155, 255, 0.15);
} }
@@ -4958,7 +4958,7 @@ select.form-control {
flex-direction: column; flex-direction: column;
transform: scale(0.95); transform: scale(0.95);
transition: transform 0.3s ease; transition: transform 0.3s ease;
z-index: 500; z-index: 1050;
margin-left: auto; margin-left: auto;
margin-right: auto; margin-right: auto;
} }
@@ -5686,11 +5686,9 @@ select.form-control {
cursor: pointer; cursor: pointer;
} }
@media (max-width: 768px) { @media (max-width: 1699px) {
.session-float { .session-float {
top: 12px; display: none;
right: 72px;
padding: 4px 10px;
} }
} }
.data-table { .data-table {
@@ -10350,7 +10348,7 @@ body.index-page-body {
transform: translateX(-100%); transform: translateX(-100%);
transition: transform 0.3s ease; transition: transform 0.3s ease;
box-shadow: 0 8px 24px rgba(75, 155, 255, 0.15); box-shadow: 0 8px 24px rgba(75, 155, 255, 0.15);
z-index: 500; z-index: 1050;
height: calc(100vh - 60px); height: calc(100vh - 60px);
margin: 0; margin: 0;
border-radius: 0; border-radius: 0;
@@ -10505,7 +10503,7 @@ body.index-page-body {
font-size: 24px; font-size: 24px;
cursor: pointer; cursor: pointer;
box-shadow: 0 8px 24px rgba(75, 155, 255, 0.15); box-shadow: 0 8px 24px rgba(75, 155, 255, 0.15);
z-index: 501; z-index: 1051;
transition: all 0.3s ease; transition: all 0.3s ease;
background-color: #0049b4; background-color: #0049b4;
} }
@@ -10757,7 +10755,7 @@ body.index-page-body {
right: 0; right: 0;
bottom: 0; bottom: 0;
background-color: rgba(0, 0, 0, 0.5); background-color: rgba(0, 0, 0, 0.5);
z-index: 499; z-index: 1049;
opacity: 0; opacity: 0;
transition: opacity 0.3s ease; transition: opacity 0.3s ease;
pointer-events: none; pointer-events: none;
@@ -13188,7 +13186,7 @@ body.index-page-body {
transform: translateX(-100%); transform: translateX(-100%);
transition: transform 0.3s ease; transition: transform 0.3s ease;
box-shadow: 0 8px 24px rgba(75, 155, 255, 0.15); box-shadow: 0 8px 24px rgba(75, 155, 255, 0.15);
z-index: 500; z-index: 1050;
height: calc(100vh - 60px); height: calc(100vh - 60px);
max-height: none; max-height: none;
margin: 0; margin: 0;
@@ -13294,7 +13292,7 @@ body.index-page-body {
color: #FFFFFF; color: #FFFFFF;
font-size: 24px; font-size: 24px;
cursor: pointer; cursor: pointer;
z-index: 501; z-index: 1051;
transition: all 0.3s ease; transition: all 0.3s ease;
} }
.apikey-mobile-toggle:hover { .apikey-mobile-toggle:hover {
@@ -13320,7 +13318,7 @@ body.index-page-body {
right: 0; right: 0;
bottom: 0; bottom: 0;
background-color: rgba(0, 0, 0, 0.5); background-color: rgba(0, 0, 0, 0.5);
z-index: 499; z-index: 1049;
opacity: 0; opacity: 0;
transition: opacity 0.3s ease; transition: opacity 0.3s ease;
pointer-events: none; pointer-events: none;
@@ -16415,7 +16413,7 @@ input[type=checkbox]:checked + .custom-checkbox {
width: 100%; width: 100%;
} }
} }
.step2-wrap .s2-floating-cart { .s2-floating-cart {
position: fixed; position: fixed;
bottom: 80px; bottom: 80px;
right: 40px; right: 40px;
@@ -16434,13 +16432,13 @@ input[type=checkbox]:checked + .custom-checkbox {
transition: all 0.3s ease; transition: all 0.3s ease;
z-index: 1001; z-index: 1001;
} }
.step2-wrap .s2-floating-cart .s2-cart-label { .s2-floating-cart .s2-cart-label {
font-size: 14px; font-size: 14px;
font-weight: 700; font-weight: 700;
color: #ffffff; color: #ffffff;
white-space: nowrap; white-space: nowrap;
} }
.step2-wrap .s2-floating-cart .s2-cart-badge { .s2-floating-cart .s2-cart-badge {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
@@ -16452,47 +16450,48 @@ input[type=checkbox]:checked + .custom-checkbox {
flex-shrink: 0; flex-shrink: 0;
margin-right: -8px; margin-right: -8px;
} }
.step2-wrap .s2-floating-cart .s2-cart-badge .s2-cart-count { .s2-floating-cart .s2-cart-badge .s2-cart-count {
font-size: 24px; font-size: 24px;
font-weight: 700; font-weight: 700;
color: #212529; color: #212529;
line-height: 1; line-height: 1;
} }
.step2-wrap .s2-floating-cart .s2-cart-badge .s2-cart-unit { .s2-floating-cart .s2-cart-badge .s2-cart-unit {
font-size: 14px; font-size: 14px;
font-weight: 700; font-weight: 700;
color: #212529; color: #212529;
line-height: 1; line-height: 1;
margin-top: 2px; margin-top: 2px;
} }
.step2-wrap .s2-floating-cart:hover { .s2-floating-cart:hover {
transform: translateY(-4px); transform: translateY(-4px);
box-shadow: 0 12px 32px rgba(59, 164, 237, 0.5); box-shadow: 0 12px 32px rgba(59, 164, 237, 0.5);
} }
@media (max-width: 768px) { @media (max-width: 768px) {
.step2-wrap .s2-floating-cart { .s2-floating-cart {
bottom: 100px; bottom: 100px;
right: 24px; right: 24px;
width: 160px; width: 160px;
height: 56px; height: 56px;
padding: 8px 12px 8px 28px; padding: 8px 12px 8px 28px;
} }
.step2-wrap .s2-floating-cart .s2-cart-label { .s2-floating-cart .s2-cart-label {
font-size: 12px; font-size: 12px;
} }
.step2-wrap .s2-floating-cart .s2-cart-badge { .s2-floating-cart .s2-cart-badge {
width: 48px; width: 48px;
height: 48px; height: 48px;
margin-right: -6px; margin-right: -6px;
} }
.step2-wrap .s2-floating-cart .s2-cart-badge .s2-cart-count { .s2-floating-cart .s2-cart-badge .s2-cart-count {
font-size: 20px; font-size: 20px;
} }
.step2-wrap .s2-floating-cart .s2-cart-badge .s2-cart-unit { .s2-floating-cart .s2-cart-badge .s2-cart-unit {
font-size: 12px; font-size: 12px;
} }
} }
.step2-wrap .s2-modal {
.s2-modal {
position: fixed; position: fixed;
top: 0; top: 0;
left: 0; left: 0;
@@ -16503,7 +16502,7 @@ input[type=checkbox]:checked + .custom-checkbox {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
} }
.step2-wrap .s2-modal .s2-modal-backdrop { .s2-modal .s2-modal-backdrop {
position: absolute; position: absolute;
top: 0; top: 0;
left: 0; left: 0;
@@ -16512,7 +16511,7 @@ input[type=checkbox]:checked + .custom-checkbox {
background-color: rgba(0, 0, 0, 0.5); background-color: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(2px); backdrop-filter: blur(2px);
} }
.step2-wrap .s2-modal .s2-modal-dialog { .s2-modal .s2-modal-dialog {
position: relative; position: relative;
background: #ffffff; background: #ffffff;
border-radius: 20px; border-radius: 20px;
@@ -16525,40 +16524,40 @@ input[type=checkbox]:checked + .custom-checkbox {
overflow: hidden; overflow: hidden;
z-index: 1; z-index: 1;
} }
.step2-wrap .s2-modal .s2-modal-header { .s2-modal .s2-modal-header {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 20px 24px; padding: 20px 24px;
border-bottom: 1px solid #e8edf5; border-bottom: 1px solid #e8edf5;
} }
.step2-wrap .s2-modal .s2-modal-header .s2-modal-title { .s2-modal .s2-modal-header .s2-modal-title {
font-size: 18px; font-size: 18px;
font-weight: 700; font-weight: 700;
color: #0d0e11; color: #0d0e11;
margin: 0; margin: 0;
} }
.step2-wrap .s2-modal .s2-modal-header .s2-modal-close { .s2-modal .s2-modal-header .s2-modal-close {
background: none; background: none;
border: none; border: none;
font-size: 20px; font-size: 20px;
cursor: pointer; cursor: pointer;
color: #94a3b8; color: #94a3b8;
} }
.step2-wrap .s2-modal .s2-modal-header .s2-modal-close:hover { .s2-modal .s2-modal-header .s2-modal-close:hover {
color: #0d0e11; color: #0d0e11;
} }
.step2-wrap .s2-modal .s2-modal-body { .s2-modal .s2-modal-body {
padding: 24px; padding: 24px;
max-height: 300px; max-height: 300px;
overflow-y: auto; overflow-y: auto;
} }
.step2-wrap .s2-modal .s2-selected-list { .s2-modal .s2-selected-list {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 8px; gap: 8px;
} }
.step2-wrap .s2-modal .s2-api-pill { .s2-modal .s2-api-pill {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
@@ -16568,13 +16567,13 @@ input[type=checkbox]:checked + .custom-checkbox {
font-size: 14px; font-size: 14px;
color: #0d0e11; color: #0d0e11;
} }
.step2-wrap .s2-modal .s2-api-pill .s2-api-pill-name { .s2-modal .s2-api-pill .s2-api-pill-name {
max-width: 250px; max-width: 250px;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.step2-wrap .s2-modal .s2-api-pill .s2-api-pill-remove { .s2-modal .s2-api-pill .s2-api-pill-remove {
background: none; background: none;
border: none; border: none;
font-size: 14px; font-size: 14px;
@@ -16585,16 +16584,16 @@ input[type=checkbox]:checked + .custom-checkbox {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
} }
.step2-wrap .s2-modal .s2-api-pill .s2-api-pill-remove:hover { .s2-modal .s2-api-pill .s2-api-pill-remove:hover {
color: #f4253c; color: #f4253c;
} }
.step2-wrap .s2-modal .s2-modal-footer { .s2-modal .s2-modal-footer {
padding: 16px 24px; padding: 16px 24px;
border-top: 1px solid #e8edf5; border-top: 1px solid #e8edf5;
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
} }
.step2-wrap .s2-modal .s2-btn-close-modal { .s2-modal .s2-btn-close-modal {
height: 40px; height: 40px;
padding: 0 20px; padding: 0 20px;
background: #f1f5f9; background: #f1f5f9;
@@ -16605,7 +16604,7 @@ input[type=checkbox]:checked + .custom-checkbox {
font-weight: 500; font-weight: 500;
cursor: pointer; cursor: pointer;
} }
.step2-wrap .s2-modal .s2-btn-close-modal:hover { .s2-modal .s2-btn-close-modal:hover {
background: #e2e8f0; background: #e2e8f0;
} }
@@ -24180,6 +24179,511 @@ input[type=checkbox]:checked + .custom-checkbox {
flex-wrap: wrap; flex-wrap: wrap;
} }
} }
.webhook-container {
max-width: 880px;
margin: 0 auto;
padding: 24px 16px 60px;
}
.webhook-container .field-help {
color: #6b7280;
font-size: 13px;
margin-top: 6px;
}
.webhook-container .field-error,
.webhook-container .webhook-field-error {
color: #e03131;
font-size: 13px;
margin-top: 6px;
}
.webhook-container .req {
color: #e03131;
}
.webhook-empty {
text-align: center;
padding: 64px 20px;
background: #f5f7fb;
border: 1px solid #e2e6ee;
border-radius: 14px;
}
.webhook-empty .empty-icon {
font-size: 48px;
}
.webhook-empty h3 {
margin: 16px 0 8px;
font-size: 20px;
}
.webhook-empty p {
color: #6b7280;
margin-bottom: 24px;
}
.webhook-steps {
display: flex;
gap: 8px;
margin-bottom: 28px;
}
.webhook-steps .webhook-step {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 12px;
border-radius: 10px;
background: #f5f7fb;
color: #6b7280;
font-size: 14px;
}
.webhook-steps .webhook-step .step-no {
width: 22px;
height: 22px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: #d5dbe6;
color: #fff;
font-size: 12px;
font-weight: 700;
}
.webhook-steps .webhook-step.active {
background: rgba(11, 95, 255, 0.1);
color: #0b5fff;
}
.webhook-steps .webhook-step.active .step-no {
background: #0b5fff;
}
.webhook-field {
margin-bottom: 24px;
}
.webhook-field label {
display: block;
font-weight: 600;
margin-bottom: 8px;
}
.webhook-field input[type=text],
.webhook-field input[type=password] {
width: 100%;
padding: 12px 14px;
border: 1px solid #e2e6ee;
border-radius: 10px;
font-size: 14px;
}
.webhook-field input[type=text]:focus,
.webhook-field input[type=password]:focus {
outline: none;
border-color: #0b5fff;
}
.webhook-eventtype-list {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
}
.webhook-eventtype-list .eventtype-item {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 14px;
border: 1px solid #e2e6ee;
border-radius: 10px;
cursor: pointer;
}
.webhook-eventtype-list .eventtype-item input {
width: 18px;
height: 18px;
}
.webhook-eventtype-list .eventtype-item .eventtype-name {
font-weight: 600;
}
.webhook-eventtype-list .eventtype-item .eventtype-code {
color: #6b7280;
font-size: 12px;
margin-left: auto;
}
.webhook-eventtype-list .eventtype-item:hover {
border-color: #0b5fff;
}
.webhook-api-groups {
margin: 16px 0 8px;
}
.webhook-api-group {
margin-bottom: 24px;
}
.webhook-api-group .group-name {
font-size: 16px;
margin-bottom: 12px;
}
.webhook-api-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 10px;
}
.webhook-api-list .webhook-api-card {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 14px;
border: 1px solid #e2e6ee;
border-radius: 10px;
cursor: pointer;
}
.webhook-api-list .webhook-api-card input {
width: 18px;
height: 18px;
}
.webhook-api-list .webhook-api-card .api-name {
font-weight: 600;
}
.webhook-api-list .webhook-api-card .api-id {
color: #6b7280;
font-size: 12px;
margin-left: auto;
}
.webhook-api-list .webhook-api-card:hover {
border-color: #0b5fff;
}
.webhook-actions {
display: flex;
justify-content: space-between;
gap: 12px;
margin-top: 32px;
}
.webhook-actions.center {
justify-content: center;
}
.btn-webhook-next,
.btn-webhook-create {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 12px 28px;
background: #0b5fff;
color: #fff;
border: none;
border-radius: 10px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
text-decoration: none;
}
.btn-webhook-next:hover,
.btn-webhook-create:hover {
background: #0847c4;
}
.btn-webhook-cancel {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 12px 28px;
background: #fff;
color: #6b7280;
border: 1px solid #e2e6ee;
border-radius: 10px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
text-decoration: none;
}
.btn-webhook-cancel:hover {
border-color: #6b7280;
}
.btn-webhook-danger {
padding: 12px 28px;
background: #fff;
color: #e03131;
border: 1px solid #e03131;
border-radius: 10px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
}
.btn-webhook-danger:hover {
background: #e03131;
color: #fff;
}
.btn-mini {
padding: 4px 12px;
background: #f5f7fb;
color: #0b5fff;
border: 1px solid #e2e6ee;
border-radius: 8px;
font-size: 12px;
cursor: pointer;
}
.btn-mini:hover {
background: rgba(11, 95, 255, 0.1);
}
.btn-copy {
padding: 10px 18px;
background: #0b5fff;
color: #fff;
border: none;
border-radius: 8px;
cursor: pointer;
}
.btn-copy:hover {
background: #0847c4;
}
.webhook-card {
border: 1px solid #e2e6ee;
border-radius: 14px;
padding: 24px;
}
.webhook-card .webhook-card-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
}
.webhook-card .webhook-card-head h3 {
font-size: 18px;
}
.webhook-card .webhook-card-head .webhook-created {
color: #6b7280;
font-size: 13px;
}
.webhook-card .webhook-card-row {
display: flex;
gap: 16px;
padding: 14px 0;
border-top: 1px solid #e2e6ee;
}
.webhook-card .webhook-card-row .row-label {
width: 110px;
color: #6b7280;
font-weight: 600;
flex-shrink: 0;
}
.webhook-card .webhook-card-row .row-value {
flex: 1;
word-break: break-all;
}
.webhook-card .webhook-card-row.secret-row, .webhook-card .webhook-card-row .secret-row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.webhook-card .webhook-card-row code {
background: #f5f7fb;
padding: 4px 8px;
border-radius: 6px;
}
.webhook-card .eventtype-badge,
.webhook-card .api-badge {
display: inline-block;
padding: 4px 10px;
margin: 2px 4px 2px 0;
border-radius: 999px;
font-size: 12px;
background: rgba(11, 95, 255, 0.1);
color: #0b5fff;
}
.webhook-card .api-badge {
background: #f5f7fb;
color: #6b7280;
}
.webhook-card .webhook-card-actions {
display: flex;
gap: 12px;
justify-content: flex-end;
margin-top: 24px;
}
.webhook-complete {
text-align: center;
padding: 24px;
}
.webhook-complete .complete-icon {
font-size: 48px;
}
.webhook-complete h3 {
margin: 16px 0;
}
.webhook-complete .webhook-secret-box {
text-align: left;
max-width: 640px;
margin: 24px auto;
padding: 20px;
background: #f5f7fb;
border: 1px solid #e2e6ee;
border-radius: 12px;
}
.webhook-complete .webhook-secret-box .secret-warning {
color: #92400e;
background: #fef3c7;
border-radius: 8px;
padding: 12px 14px;
margin-bottom: 16px;
font-size: 13px;
}
.webhook-complete .webhook-secret-box label {
display: block;
font-weight: 600;
margin-bottom: 8px;
}
.webhook-complete .webhook-secret-box .secret-value-row {
display: flex;
gap: 8px;
}
.webhook-complete .webhook-secret-box .secret-value-row input {
flex: 1;
padding: 12px 14px;
border: 1px solid #e2e6ee;
border-radius: 10px;
font-family: monospace;
}
.step1-wrap .s1-form-card .webhook-card-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
}
.step1-wrap .s1-form-card .webhook-card-head h3 {
font-size: 18px;
}
.step1-wrap .s1-form-card .webhook-card-head .webhook-created {
color: #6b7280;
font-size: 13px;
}
.step1-wrap .s1-form-card .webhook-card-row {
display: flex;
gap: 16px;
padding: 14px 0;
border-top: 1px solid #e2e6ee;
}
.step1-wrap .s1-form-card .webhook-card-row .row-label {
width: 110px;
color: #6b7280;
font-weight: 600;
flex-shrink: 0;
}
.step1-wrap .s1-form-card .webhook-card-row .row-value {
flex: 1;
word-break: break-all;
}
.step1-wrap .s1-form-card .webhook-card-row .secret-row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.step1-wrap .s1-form-card .webhook-card-row code {
background: #f5f7fb;
padding: 4px 8px;
border-radius: 6px;
}
.step1-wrap .s1-form-card .eventtype-badge,
.step1-wrap .s1-form-card .api-badge {
display: inline-block;
padding: 4px 10px;
margin: 2px 4px 2px 0;
border-radius: 999px;
font-size: 12px;
background: rgba(11, 95, 255, 0.1);
color: #0b5fff;
}
.step1-wrap .s1-form-card .api-badge {
background: #f5f7fb;
color: #6b7280;
}
.step1-wrap .s1-form-card .webhook-empty {
background: none;
border: none;
padding: 24px 0;
}
.step1-wrap .s1-actions .btn-webhook-cancel,
.step1-wrap .s1-actions .btn-webhook-danger,
.step2-wrap .s1-actions .btn-webhook-cancel,
.step2-wrap .s1-actions .btn-webhook-danger,
.step3-wrap .s1-actions .btn-webhook-cancel,
.step3-wrap .s1-actions .btn-webhook-danger {
margin-right: auto;
}
.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;
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.webhook-modal .webhook-modal-box {
background: #fff;
border-radius: 14px;
padding: 28px;
width: 360px;
max-width: 90vw;
}
.webhook-modal .webhook-modal-box h4 {
font-size: 18px;
margin-bottom: 8px;
}
.webhook-modal .webhook-modal-box p {
color: #6b7280;
font-size: 14px;
margin-bottom: 16px;
}
.webhook-modal .webhook-modal-box input {
width: 100%;
padding: 12px 14px;
border: 1px solid #e2e6ee;
border-radius: 10px;
margin-bottom: 8px;
}
.webhook-modal .webhook-modal-box input:focus {
outline: none;
border-color: #0b5fff;
}
.webhook-modal .webhook-modal-box .webhook-modal-actions {
display: flex;
gap: 12px;
justify-content: flex-end;
margin-top: 12px;
}
.webhook-modal .webhook-modal-box .webhook-modal-actions .btn-webhook-next, .webhook-modal .webhook-modal-box .webhook-modal-actions .btn-webhook-cancel {
padding: 10px 20px;
font-size: 14px;
}
.d-none { .d-none {
display: none !important; display: none !important;
} }
File diff suppressed because one or more lines are too long
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,467 @@
/**
* API 선택 공용 모듈 (fragment/api_selector.html 전용, figma s2 디자인)
*
* 사용처: 앱(API Key) 신청/수정 step2, Webhook 신청/수정 step2
*
* 계약:
* - 폼: #apiSelectorForm (data-save-action = "이전" 저장 POST 경로)
* - 기선택: window.API_SELECTOR_SELECTED / 목록 URL: window.API_SELECTOR_LIST_URL (fragment 인라인 주입)
* - "이전" 버튼: 호출 페이지의 #btnPrevStep (없으면 스킵)
* - 카트/모달: fragment `apiSelectorPopups` 를 pagePopups 슬롯에서 호출(body 직속)
*
* design(figma s2) 인라인 스크립트 대비 패치 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 menuTitles = document.querySelectorAll('.s2-category-tab');
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('.s2-api-card').forEach(card => card.remove());
const baseUrl = window.API_SELECTOR_LIST_URL || '/apis/for_request';
let url = baseUrl;
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';
document.getElementById('apiResultCount').textContent = '0';
return;
}
document.getElementById('apiResultCount').textContent = apis.length;
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';
document.getElementById('apiResultCount').textContent = '0';
});
}
// 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 (figma s2 card)
function createApiCard(api) {
const card = document.createElement('div');
card.className = 's2-api-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="s2-api-card-badge">
<span>${api.apiGroupName || api.service || '카테고리'}</span>
</div>
<!-- Checkbox container with visible custom design -->
<label class="s2-checkbox-wrapper">
<input type="checkbox"
name="selectedApis"
value="${api.apiId}"
id="api-${api.apiId}"
class="s2-api-checkbox visually-hidden"
${isSelected ? 'checked' : ''}>
<span class="s2-checkbox-custom"></span>
</label>
<h3 class="s2-api-card-title">${api.apiName || 'API 이름'}</h3>
<p class="s2-api-card-desc">${api.apiSimpleDescription || 'API 설명이 없습니다.'}</p>
<div class="s2-api-card-image">
${mainIconHtml}
</div>
`;
return card;
}
// Attach event listeners to cards
function attachCardEventListeners() {
const apiCards = document.querySelectorAll('.s2-api-card');
apiCards.forEach(card => {
card.addEventListener('click', function(e) {
const checkbox = card.querySelector('.s2-api-checkbox');
if (checkbox) {
checkbox.checked = !checkbox.checked;
updateCardSelection(checkbox);
updateSelectedCount();
}
});
});
// Prevent double toggle when clicking the checkbox wrapper
const checkboxWrappers = document.querySelectorAll('.s2-checkbox-wrapper');
checkboxWrappers.forEach(wrapper => {
wrapper.addEventListener('click', function(e) {
e.stopPropagation(); // Stop click from bubbling to card!
});
const checkbox = wrapper.querySelector('.s2-api-checkbox');
if (checkbox) {
checkbox.addEventListener('change', function() {
updateCardSelection(this);
updateSelectedCount();
});
}
});
}
// Update card visual state
function updateCardSelection(checkbox) {
const card = checkbox.closest('.s2-api-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('.s2-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('.s2-api-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('.s2-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="s2-empty-message">선택된 API가 없습니다.</p>';
return;
}
selectedApis.forEach(function(apiId) {
const card = document.querySelector('.s2-api-card[data-api-id="' + apiId + '"]');
const apiName = card ? card.querySelector('.s2-api-card-title').textContent : apiId;
const apiPill = document.createElement('div');
apiPill.className = 's2-api-pill';
apiPill.innerHTML = `
<span class="s2-api-pill-name">${apiName}</span>
<button type="button" class="s2-api-pill-remove" data-value="${apiId}" aria-label="Remove ${apiName}">✕</button>
`;
modalSelectedList.appendChild(apiPill);
});
document.querySelectorAll('.s2-api-pill-remove').forEach(function(btn) {
btn.addEventListener('click', function() {
const value = this.getAttribute('data-value');
selectedApis.delete(value);
const checkbox = document.querySelector('.s2-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('.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();
});
}
// Category tab 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();
loadApis(groupId);
if (searchInput) {
searchInput.value = '';
}
});
});
// Category Carousel Scroll
const categoryListWrapper = document.getElementById('categoryListWrapper');
const btnPrevCategory = document.getElementById('btnPrevCategory');
const btnNextCategory = document.getElementById('btnNextCategory');
if (categoryListWrapper && btnPrevCategory && btnNextCategory) {
const scrollAmount = 200;
btnPrevCategory.addEventListener('click', function() {
categoryListWrapper.scrollBy({ left: -scrollAmount, behavior: 'smooth' });
});
btnNextCategory.addEventListener('click', function() {
categoryListWrapper.scrollBy({ left: scrollAmount, behavior: 'smooth' });
});
// Toggle buttons visibility/disabled state based on scroll position
function updateCarouselButtons() {
const scrollLeft = categoryListWrapper.scrollLeft;
const maxScrollLeft = categoryListWrapper.scrollWidth - categoryListWrapper.clientWidth;
btnPrevCategory.disabled = scrollLeft <= 0;
btnNextCategory.disabled = scrollLeft >= maxScrollLeft - 1;
}
categoryListWrapper.addEventListener('scroll', updateCarouselButtons);
window.addEventListener('resize', updateCarouselButtons);
// Initial check after loading categories
setTimeout(updateCarouselButtons, 150);
}
// 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 = 'flex'; // .s2-modal은 flex 중앙정렬 → 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 === 'flex') {
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('.s2-api-card')).filter(card => card.style.display !== 'none');
visibleCards.forEach(function(card) {
const checkbox = card.querySelector('.s2-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('.s2-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('');
});
@@ -101,8 +101,9 @@ $z-index-dropdown: 100;
$z-index-sticky: 200; $z-index-sticky: 200;
$z-index-fixed: 300; $z-index-fixed: 300;
$z-index-header: 350; $z-index-header: 350;
$z-index-modal-backdrop: 400; // 모달은 헤더(.global-header z-index:1000 하드코딩)보다 항상 위에 떠야 한다
$z-index-modal: 500; $z-index-modal-backdrop: 1040;
$z-index-modal: 1050;
$z-index-popover: 600; $z-index-popover: 600;
$z-index-tooltip: 700; $z-index-tooltip: 700;
$z-index-notification: 800; $z-index-notification: 800;
@@ -71,12 +71,12 @@
} }
} }
// 모바일: 헤더 우측 햄버거(메뉴 버튼) 바로 왼쪽에 나란히 배치. // 가로 여유가 있을 때만 노출.
// right = 헤더 padding(20px) + 햄버거 폭(~32px) + 간격(~20px) ≈ 72px // 헤더 .container(max-width:1280px, 중앙정렬)의 우측 여백에 위젯(폭 ~200px)이 들어갈 만큼
@media (max-width: $breakpoint-sm) { // 넓은 화면(약 1700px 이상)에서만 표시 → 그 외에는 헤더 우측 메뉴(사용자명·로그아웃·마이페이지)를 가리므로 숨김.
// (PC 전용: 모바일 ≤768px 도 당연히 숨김)
@media (max-width: 1699px) {
.session-float { .session-float {
top: 12px; display: none;
right: 72px;
padding: 4px 10px;
} }
} }
+1
View File
@@ -66,6 +66,7 @@
@use 'pages/terms-agreements' as *; @use 'pages/terms-agreements' as *;
@use 'pages/service' as *; @use 'pages/service' as *;
@use 'pages/api-statistics' as *; @use 'pages/api-statistics' as *;
@use 'pages/webhook' as *;
// 6. Themes // 6. Themes
@use 'themes/dark' as *; @use 'themes/dark' as *;
@@ -4027,7 +4027,8 @@ input[type="checkbox"]:checked+.custom-checkbox {
} }
// ── Floating Cart Button ── // ── Floating Cart Button ──
.s2-floating-cart { // @at-root: body 직속(pagePopups)으로 렌더되므로 step2-wrap 스코프 밖 전역 규칙으로 컴파일
@at-root .s2-floating-cart {
position: fixed; position: fixed;
bottom: 80px; bottom: 80px;
right: 40px; right: 40px;
@@ -4114,7 +4115,8 @@ input[type="checkbox"]:checked+.custom-checkbox {
} }
// ── Selected APIs Modal ── // ── Selected APIs Modal ──
.s2-modal { // @at-root: body 직속(pagePopups)으로 렌더되므로 step2-wrap 스코프 밖 전역 규칙으로 컴파일
@at-root .s2-modal {
position: fixed; position: fixed;
top: 0; top: 0;
left: 0; left: 0;
@@ -0,0 +1,341 @@
// Webhook 신청/관리 (DJPGPT0004)
// App API Key 신청 3-step UI 톤을 답습한 자체 완결 스타일.
$wh-primary: #0b5fff;
$wh-primary-dark: #0847c4;
$wh-danger: #e03131;
$wh-border: #e2e6ee;
$wh-muted: #6b7280;
$wh-bg-soft: #f5f7fb;
.webhook-container {
max-width: 880px;
margin: 0 auto;
padding: 24px 16px 60px;
.field-help { color: $wh-muted; font-size: 13px; margin-top: 6px; }
.field-error,
.webhook-field-error { color: $wh-danger; font-size: 13px; margin-top: 6px; }
.req { color: $wh-danger; }
}
// ---------- 빈 상태 ----------
.webhook-empty {
text-align: center;
padding: 64px 20px;
background: $wh-bg-soft;
border: 1px solid $wh-border;
border-radius: 14px;
.empty-icon { font-size: 48px; }
h3 { margin: 16px 0 8px; font-size: 20px; }
p { color: $wh-muted; margin-bottom: 24px; }
}
// ---------- 진행 표시 ----------
.webhook-steps {
display: flex;
gap: 8px;
margin-bottom: 28px;
.webhook-step {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 12px;
border-radius: 10px;
background: $wh-bg-soft;
color: $wh-muted;
font-size: 14px;
.step-no {
width: 22px; height: 22px;
display: inline-flex; align-items: center; justify-content: center;
border-radius: 50%;
background: #d5dbe6; color: #fff; font-size: 12px; font-weight: 700;
}
&.active {
background: rgba($wh-primary, 0.1);
color: $wh-primary;
.step-no { background: $wh-primary; }
}
}
}
// ---------- 폼 ----------
.webhook-field {
margin-bottom: 24px;
label { display: block; font-weight: 600; margin-bottom: 8px; }
input[type="text"],
input[type="password"] {
width: 100%;
padding: 12px 14px;
border: 1px solid $wh-border;
border-radius: 10px;
font-size: 14px;
&:focus { outline: none; border-color: $wh-primary; }
}
}
.webhook-eventtype-list {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
.eventtype-item {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 14px;
border: 1px solid $wh-border;
border-radius: 10px;
cursor: pointer;
input { width: 18px; height: 18px; }
.eventtype-name { font-weight: 600; }
.eventtype-code { color: $wh-muted; font-size: 12px; margin-left: auto; }
&:hover { border-color: $wh-primary; }
}
}
// ---------- API 선택 ----------
.webhook-api-groups { margin: 16px 0 8px; }
.webhook-api-group {
margin-bottom: 24px;
.group-name { font-size: 16px; margin-bottom: 12px; }
}
.webhook-api-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 10px;
.webhook-api-card {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 14px;
border: 1px solid $wh-border;
border-radius: 10px;
cursor: pointer;
input { width: 18px; height: 18px; }
.api-name { font-weight: 600; }
.api-id { color: $wh-muted; font-size: 12px; margin-left: auto; }
&:hover { border-color: $wh-primary; }
}
}
// ---------- 액션 버튼 ----------
.webhook-actions {
display: flex;
justify-content: space-between;
gap: 12px;
margin-top: 32px;
&.center { justify-content: center; }
}
.btn-webhook-next,
.btn-webhook-create {
display: inline-flex; align-items: center; justify-content: center;
padding: 12px 28px;
background: $wh-primary; color: #fff;
border: none; border-radius: 10px;
font-size: 15px; font-weight: 600; cursor: pointer; text-decoration: none;
&:hover { background: $wh-primary-dark; }
}
.btn-webhook-cancel {
display: inline-flex; align-items: center; justify-content: center;
padding: 12px 28px;
background: #fff; color: $wh-muted;
border: 1px solid $wh-border; border-radius: 10px;
font-size: 15px; font-weight: 600; cursor: pointer; text-decoration: none;
&:hover { border-color: $wh-muted; }
}
.btn-webhook-danger {
padding: 12px 28px;
background: #fff; color: $wh-danger;
border: 1px solid $wh-danger; border-radius: 10px;
font-size: 15px; font-weight: 600; cursor: pointer;
&:hover { background: $wh-danger; color: #fff; }
}
.btn-mini {
padding: 4px 12px;
background: $wh-bg-soft; color: $wh-primary;
border: 1px solid $wh-border; border-radius: 8px;
font-size: 12px; cursor: pointer;
&:hover { background: rgba($wh-primary, 0.1); }
}
.btn-copy {
padding: 10px 18px;
background: $wh-primary; color: #fff;
border: none; border-radius: 8px; cursor: pointer;
&:hover { background: $wh-primary-dark; }
}
// ---------- 등록 카드 ----------
.webhook-card {
border: 1px solid $wh-border;
border-radius: 14px;
padding: 24px;
.webhook-card-head {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 20px;
h3 { font-size: 18px; }
.webhook-created { color: $wh-muted; font-size: 13px; }
}
.webhook-card-row {
display: flex;
gap: 16px;
padding: 14px 0;
border-top: 1px solid $wh-border;
.row-label { width: 110px; color: $wh-muted; font-weight: 600; flex-shrink: 0; }
.row-value { flex: 1; word-break: break-all; }
&.secret-row, .secret-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
code { background: $wh-bg-soft; padding: 4px 8px; border-radius: 6px; }
}
.eventtype-badge,
.api-badge {
display: inline-block;
padding: 4px 10px; margin: 2px 4px 2px 0;
border-radius: 999px; font-size: 12px;
background: rgba($wh-primary, 0.1); color: $wh-primary;
}
.api-badge { background: $wh-bg-soft; color: $wh-muted; }
.webhook-card-actions {
display: flex; gap: 12px; justify-content: flex-end;
margin-top: 24px;
}
}
// ---------- 완료 화면 ----------
.webhook-complete {
text-align: center;
padding: 24px;
.complete-icon { font-size: 48px; }
h3 { margin: 16px 0; }
.webhook-secret-box {
text-align: left;
max-width: 640px; margin: 24px auto;
padding: 20px;
background: $wh-bg-soft; border: 1px solid $wh-border; border-radius: 12px;
.secret-warning {
color: #92400e; background: #fef3c7;
border-radius: 8px; padding: 12px 14px; margin-bottom: 16px; font-size: 13px;
}
label { display: block; font-weight: 600; margin-bottom: 8px; }
.secret-value-row {
display: flex; gap: 8px;
input { flex: 1; padding: 12px 14px; border: 1px solid $wh-border; border-radius: 10px; font-family: monospace; }
}
}
}
// ---------- figma s1-form-card 내부 재사용 ----------
// step1-wrap(s1 디자인) 카드 안에서 등록 카드 요소를 쓸 때
// 루트 카드 스타일은 .s1-form-card 것을 쓰고 내부 row/배지만 매핑한다.
.step1-wrap .s1-form-card {
.webhook-card-head {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 20px;
h3 { font-size: 18px; }
.webhook-created { color: $wh-muted; font-size: 13px; }
}
.webhook-card-row {
display: flex;
gap: 16px;
padding: 14px 0;
border-top: 1px solid $wh-border;
.row-label { width: 110px; color: $wh-muted; font-weight: 600; flex-shrink: 0; }
.row-value { flex: 1; word-break: break-all; }
.secret-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
code { background: $wh-bg-soft; padding: 4px 8px; border-radius: 6px; }
}
.eventtype-badge,
.api-badge {
display: inline-block;
padding: 4px 10px; margin: 2px 4px 2px 0;
border-radius: 999px; font-size: 12px;
background: rgba($wh-primary, 0.1); color: $wh-primary;
}
.api-badge { background: $wh-bg-soft; color: $wh-muted; }
// 빈 상태: s1 카드 안에서는 이중 박스 제거
.webhook-empty {
background: none;
border: none;
padding: 24px 0;
}
}
// s1-actions 안 취소/이전/삭제 버튼: 좌측 배치
.step1-wrap .s1-actions,
.step2-wrap .s1-actions,
.step3-wrap .s1-actions {
.btn-webhook-cancel,
.btn-webhook-danger { margin-right: auto; }
}
// ---------- 개발가이드 링크 ----------
.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;
background: rgba(0, 0, 0, 0.45);
display: flex; align-items: center; justify-content: center;
z-index: 1000;
.webhook-modal-box {
background: #fff; border-radius: 14px;
padding: 28px; width: 360px; max-width: 90vw;
h4 { font-size: 18px; margin-bottom: 8px; }
p { color: $wh-muted; font-size: 14px; margin-bottom: 16px; }
input {
width: 100%; padding: 12px 14px;
border: 1px solid $wh-border; border-radius: 10px; margin-bottom: 8px;
&:focus { outline: none; border-color: $wh-primary; }
}
.webhook-modal-actions {
display: flex; gap: 12px; justify-content: flex-end; margin-top: 12px;
.btn-webhook-next, .btn-webhook-cancel { padding: 10px 20px; font-size: 14px; }
}
}
}
@@ -19,6 +19,7 @@
<!-- Content Area --> <!-- Content Area -->
<div class="app-management-content"> <div class="app-management-content">
<!-- 모든 스타일은 step2-wrap 안에서만 적용 -->
<div class="step2-wrap"> <div class="step2-wrap">
<!-- Title --> <!-- Title -->
@@ -27,7 +28,7 @@
<!-- Progress Steps Card --> <!-- Progress Steps Card -->
<div class="s1-progress-card"> <div class="s1-progress-card">
<div class="s1-steps"> <div class="s1-steps">
<!-- Step 1: 앱 정보입력 (completed) --> <!-- Step 1: 앱 정보수정 (completed) -->
<div class="s1-step"> <div class="s1-step">
<div class="s1-step-circle"> <div class="s1-step-circle">
<!-- 입력폼 아이콘 --> <!-- 입력폼 아이콘 -->
@@ -66,7 +67,7 @@
<div class="s1-step-line"></div> <div class="s1-step-line"></div>
<!-- Step 3: 앱 생성 완료 (rocket icon) --> <!-- Step 3: 앱 수정 완료 (rocket icon) -->
<div class="s1-step"> <div class="s1-step">
<div class="s1-step-circle"> <div class="s1-step-circle">
<!-- 로켓 아이콘 --> <!-- 로켓 아이콘 -->
@@ -90,85 +91,19 @@
</div> </div>
</div> </div>
<!-- Category Carousel Tab Container --> <!-- API 선택 공용 모듈 -->
<div class="s2-category-carousel-container"> <th:block th:replace="~{fragment/api_selector :: apiSelector(${apiServices}, ${apiKeyModification.selectedApis}, '/myapikey/modify/step2', '/myapikey/modify/step2/save')}"/>
<button type="button" class="s2-carousel-btn s2-carousel-btn--prev" id="btnPrevCategory" aria-label="이전 카테고리">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="15 18 9 12 15 6"></polyline>
</svg>
</button>
<div class="s2-category-list-wrapper" id="categoryListWrapper">
<div class="s2-category-list" id="categoryList">
<button type="button" class="s2-category-tab active" data-group="">
전체
</button>
<button type="button" class="s2-category-tab" th:each="service : ${apiServices}" th:data-group="${service.id}" th:text="${service.groupName}">
서비스명
</button>
</div>
</div>
<button type="button" class="s2-carousel-btn s2-carousel-btn--next" id="btnNextCategory" aria-label="다음 카테고리">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="9 18 15 12 9 6"></polyline>
</svg>
</button>
</div>
<!-- Main Content Area --> <!-- clientId 는 모듈 폼에 form 속성으로 주입 -->
<div class="s2-selection-container"> <input type="hidden" name="clientId" th:value="${apiKeyModification.clientId}" form="apiSelectorForm"/>
<main class="s2-content-area">
<form id="modifyStep2Form" method="post" th:action="@{/myapikey/modify/step2}" th:object="${apiKeyModification}" class="s2-form">
<!-- Hidden field for clientId -->
<input type="hidden" th:field="*{clientId}"/>
<!-- Header filter: Search and Select All -->
<div class="s2-filter-header">
<div class="s2-select-all" id="selectAllWrapper" style="display: none;">
<label class="s2-select-all-label">
<input type="checkbox" id="selectAllCheckbox" class="visually-hidden">
<span class="s2-checkbox-custom"></span>
<span id="selectAllText">전체 선택</span>
</label>
</div>
<div class="s2-result-count">
<strong id="apiResultCount">0</strong>
</div>
<div class="s2-search-box">
<input type="text" id="apiSearch" class="s2-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="s2-cards-grid" id="apiCardGrid">
<!-- Loading state -->
<div class="s2-loading" id="loadingState" style="grid-column: 1 / -1;">
<div class="s2-spinner"></div>
<p>API 목록을 불러오는 중...</p>
</div>
<!-- Empty state -->
<div class="s2-empty" id="emptyState" style="display: none; grid-column: 1 / -1;">
<div class="s2-empty-icon">📦</div>
<h3>API를 선택해주세요</h3>
<p>상단 카테고리에서 서비스를 선택하면 해당 API 목록이 표시됩니다.</p>
</div>
</div>
</form>
</main>
</div>
<!-- Bottom Navigation Actions --> <!-- Bottom Navigation Actions -->
<div class="s2-actions"> <div class="s2-actions">
<button type="button" id="btnPrevStep" class="s2-btn-prev"> <button type="button" id="btnPrevStep" class="s2-btn-prev">
이전 이전
</button> </button>
<button type="submit" form="modifyStep2Form" class="s2-btn-save"> <button type="submit" form="apiSelectorForm" class="s2-btn-save">
다음 저장
</button> </button>
</div> </div>
@@ -176,549 +111,11 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Floating Cart Button -->
<button type="button" class="s2-floating-cart" id="floatingCartBtn" style="display: none;">
<span class="s2-cart-label">선택된 API</span>
<span class="s2-cart-badge" id="cartBadge">
<span class="s2-cart-count">0</span>
<span class="s2-cart-unit"></span>
</span>
</button>
<!-- Selected APIs Modal -->
<div class="s2-modal" id="selectedApisModal" style="display: none;">
<div class="s2-modal-backdrop" id="modalOverlay"></div>
<div class="s2-modal-dialog">
<div class="s2-modal-header">
<h3 class="s2-modal-title">선택된 API 목록</h3>
<button type="button" class="s2-modal-close" id="modalCloseBtn"></button>
</div>
<div class="s2-modal-body">
<div class="s2-selected-list" id="modalSelectedList">
<!-- Dynamically populated -->
</div>
</div>
<div class="s2-modal-footer">
<button type="button" class="s2-btn-close-modal" id="modalCancelBtn">닫기</button>
</div>
</div>
</div>
</th:block> </th:block>
<th:block layout:fragment="contentScript"> <!-- 화면 전체 오버레이/플로팅은 body 직속(pagePopups)으로 렌더 → wrapper transform·overflow 영향 없이 뷰포트 기준 중앙 정렬 -->
<script th:inline="javascript"> <section layout:fragment="pagePopups">
document.addEventListener('DOMContentLoaded', function() { <th:block th:replace="~{fragment/api_selector :: apiSelectorPopups}"/>
// DOM Elements </section>
const form = document.getElementById('modifyStep2Form');
const searchInput = document.getElementById('apiSearch');
const menuTitles = document.querySelectorAll('.s2-category-tab');
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
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() {
const baseUrl = /*[[@{/apis/for_request}]]*/ '/apis/for_request';
fetch(baseUrl)
.then(response => response.json())
.then(apis => {
allLoadedApis = apis;
})
.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('.s2-api-card').forEach(card => card.remove());
// Build URL with optional groupId filter (Thymeleaf contextPath safe)
const baseUrl = /*[[@{/apis/for_request}]]*/ '/apis/for_request';
let url = baseUrl;
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';
document.getElementById('apiResultCount').textContent = '0';
return;
}
document.getElementById('apiResultCount').textContent = apis.length;
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';
document.getElementById('apiResultCount').textContent = '0';
});
}
// 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 = 's2-api-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="s2-api-card-badge">
<span>${api.apiGroupName || '카테고리'}</span>
</div>
<!-- Checkbox container with visible custom design -->
<label class="s2-checkbox-wrapper">
<input type="checkbox"
name="selectedApis"
value="${api.apiId}"
id="api-${api.apiId}"
class="s2-api-checkbox visually-hidden"
${isSelected ? 'checked' : ''}>
<span class="s2-checkbox-custom"></span>
</label>
<h3 class="s2-api-card-title">${api.apiName || 'API 이름'}</h3>
<p class="s2-api-card-desc">${api.apiSimpleDescription || 'API 설명이 없습니다.'}</p>
<div class="s2-api-card-image">
${mainIconHtml}
</div>
`;
return card;
}
// Attach event listeners to cards
function attachCardEventListeners() {
const apiCards = document.querySelectorAll('.s2-api-card');
apiCards.forEach(card => {
card.addEventListener('click', function(e) {
const checkbox = card.querySelector('.s2-api-checkbox');
if (checkbox) {
checkbox.checked = !checkbox.checked;
updateCardSelection(checkbox);
updateSelectedCount();
}
});
});
// Prevent double toggle when clicking the checkbox wrapper
const checkboxWrappers = document.querySelectorAll('.s2-checkbox-wrapper');
checkboxWrappers.forEach(wrapper => {
wrapper.addEventListener('click', function(e) {
e.stopPropagation(); // Stop click from bubbling to card!
});
// Listen to checkbox change event inside it
const checkbox = wrapper.querySelector('.s2-api-checkbox');
if (checkbox) {
checkbox.addEventListener('change', function() {
updateCardSelection(this);
updateSelectedCount();
});
}
});
}
// Update card visual state
function updateCardSelection(checkbox) {
const card = checkbox.closest('.s2-api-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('.s2-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('.s2-api-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('.s2-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="s2-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('.s2-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('.s2-api-card');
if (card) {
const nameElement = card.querySelector('.s2-api-card-title');
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 && allLoadedApis.length > 0) {
api = allLoadedApis.find(a => a.apiId === apiId);
}
if (api && api.apiName) {
apiName = api.apiName;
}
}
const apiPill = document.createElement('div');
apiPill.className = 's2-api-pill';
apiPill.innerHTML = `
<span class="s2-api-pill-name">${apiName}</span>
<button type="button" class="s2-api-pill-remove" data-value="${apiId}" aria-label="Remove ${apiName}">✕</button>
`;
modalSelectedList.appendChild(apiPill);
});
// Add remove handlers
document.querySelectorAll('.s2-api-pill-remove').forEach(function(btn) {
btn.addEventListener('click', function() {
const value = this.getAttribute('data-value');
const checkbox = document.querySelector('.s2-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('.s2-api-card');
let visibleCount = 0;
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 = '';
visibleCount++;
} else {
card.style.display = 'none';
}
});
document.getElementById('apiResultCount').textContent = visibleCount;
// Update select all checkbox state after search
updateSelectAllCheckboxState();
});
}
// Category tab 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();
// Load APIs for selected service
loadApis(groupId);
// Clear search when changing category
if (searchInput) {
searchInput.value = '';
}
});
});
// Category Carousel Scroll
const categoryListWrapper = document.getElementById('categoryListWrapper');
const btnPrevCategory = document.getElementById('btnPrevCategory');
const btnNextCategory = document.getElementById('btnNextCategory');
if (categoryListWrapper && btnPrevCategory && btnNextCategory) {
const scrollAmount = 200;
btnPrevCategory.addEventListener('click', function() {
categoryListWrapper.scrollBy({ left: -scrollAmount, behavior: 'smooth' });
});
btnNextCategory.addEventListener('click', function() {
categoryListWrapper.scrollBy({ left: scrollAmount, behavior: 'smooth' });
});
// Toggle buttons visibility/disabled state based on scroll position
function updateCarouselButtons() {
const scrollLeft = categoryListWrapper.scrollLeft;
const maxScrollLeft = categoryListWrapper.scrollWidth - categoryListWrapper.clientWidth;
btnPrevCategory.disabled = scrollLeft <= 0;
btnNextCategory.disabled = scrollLeft >= maxScrollLeft - 1;
}
categoryListWrapper.addEventListener('scroll', updateCarouselButtons);
window.addEventListener('resize', updateCarouselButtons);
// Initial check after loading categories
setTimeout(updateCarouselButtons, 150);
}
// 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('.s2-api-card')).filter(card => card.style.display !== 'none');
visibleCards.forEach(function(card) {
const checkbox = card.querySelector('.s2-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 (Thymeleaf contextPath safe)
form.action = /*[[@{/myapikey/modify/step2/save}]]*/ '/myapikey/modify/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;
}
});
// Initialize
loadAllApisForModal();
updateSelectedCount(); // This will show count from restored session data
// Load all APIs automatically on page load (empty string = all APIs)
loadApis('');
});
</script>
</th:block>
</body> </body>
</html> </html>
@@ -91,81 +91,15 @@
</div> </div>
</div> </div>
<!-- Category Carousel Tab Container --> <!-- API 선택 공용 모듈 -->
<div class="s2-category-carousel-container"> <th:block th:replace="~{fragment/api_selector :: apiSelector(${apiServices}, ${apiKeyRegistration.selectedApis}, '/myapikey/register/step2', '/myapikey/register/step2/save')}"/>
<button type="button" class="s2-carousel-btn s2-carousel-btn--prev" id="btnPrevCategory" aria-label="이전 카테고리">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="15 18 9 12 15 6"></polyline>
</svg>
</button>
<div class="s2-category-list-wrapper" id="categoryListWrapper">
<div class="s2-category-list" id="categoryList">
<button type="button" class="s2-category-tab active" data-group="">
전체
</button>
<button type="button" class="s2-category-tab" th:each="service : ${apiServices}" th:data-group="${service.id}" th:text="${service.groupName}">
서비스명
</button>
</div>
</div>
<button type="button" class="s2-carousel-btn s2-carousel-btn--next" id="btnNextCategory" aria-label="다음 카테고리">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="9 18 15 12 9 6"></polyline>
</svg>
</button>
</div>
<!-- Main Content Area -->
<div class="s2-selection-container">
<main class="s2-content-area">
<form id="registerStep2Form" method="post" th:action="@{/myapikey/register/step2}" class="s2-form">
<!-- Header filter: Search and Select All -->
<div class="s2-filter-header">
<div class="s2-select-all" id="selectAllWrapper" style="display: none;">
<label class="s2-select-all-label">
<input type="checkbox" id="selectAllCheckbox" class="visually-hidden">
<span class="s2-checkbox-custom"></span>
<span id="selectAllText">전체 선택</span>
</label>
</div>
<div class="s2-result-count">
<strong id="apiResultCount">0</strong>
</div>
<div class="s2-search-box">
<input type="text" id="apiSearch" class="s2-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="s2-cards-grid" id="apiCardGrid">
<!-- Loading state -->
<div class="s2-loading" id="loadingState" style="grid-column: 1 / -1;">
<div class="s2-spinner"></div>
<p>API 목록을 불러오는 중...</p>
</div>
<!-- Empty state -->
<div class="s2-empty" id="emptyState" style="display: none; grid-column: 1 / -1;">
<div class="s2-empty-icon">📦</div>
<h3>API를 선택해주세요</h3>
<p>상단 카테고리에서 서비스를 선택하면 해당 API 목록이 표시됩니다.</p>
</div>
</div>
</form>
</main>
</div>
<!-- Bottom Navigation Actions --> <!-- Bottom Navigation Actions -->
<div class="s2-actions"> <div class="s2-actions">
<button type="button" id="btnPrevStep" class="s2-btn-prev"> <button type="button" id="btnPrevStep" class="s2-btn-prev">
이전 이전
</button> </button>
<button type="submit" form="registerStep2Form" class="s2-btn-save"> <button type="submit" form="apiSelectorForm" class="s2-btn-save">
다음 다음
</button> </button>
</div> </div>
@@ -174,518 +108,11 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Floating Cart Button -->
<button type="button" class="s2-floating-cart" id="floatingCartBtn" style="display: none;">
<span class="s2-cart-label">선택된 API</span>
<span class="s2-cart-badge" id="cartBadge">
<span class="s2-cart-count">0</span>
<span class="s2-cart-unit"></span>
</span>
</button>
<!-- Selected APIs Modal -->
<div class="s2-modal" id="selectedApisModal" style="display: none;">
<div class="s2-modal-backdrop" id="modalOverlay"></div>
<div class="s2-modal-dialog">
<div class="s2-modal-header">
<h3 class="s2-modal-title">선택된 API 목록</h3>
<button type="button" class="s2-modal-close" id="modalCloseBtn"></button>
</div>
<div class="s2-modal-body">
<div class="s2-selected-list" id="modalSelectedList">
<!-- Dynamically populated -->
</div>
</div>
<div class="s2-modal-footer">
<button type="button" class="s2-btn-close-modal" id="modalCancelBtn">닫기</button>
</div>
</div>
</div>
</th:block> </th:block>
<th:block layout:fragment="contentScript"> <!-- 화면 전체 오버레이/플로팅은 body 직속(pagePopups)으로 렌더 → wrapper transform·overflow 영향 없이 뷰포트 기준 중앙 정렬 -->
<script th:inline="javascript"> <section layout:fragment="pagePopups">
document.addEventListener('DOMContentLoaded', function() { <th:block th:replace="~{fragment/api_selector :: apiSelectorPopups}"/>
// DOM Elements </section>
const form = document.getElementById('registerStep2Form');
const searchInput = document.getElementById('apiSearch');
const menuTitles = document.querySelectorAll('.s2-category-tab');
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('.s2-api-card').forEach(card => card.remove());
// Build URL with optional groupId filter (Thymeleaf contextPath safe)
const baseUrl = /*[[@{/apis/for_request}]]*/ '/apis/for_request';
let url = baseUrl;
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';
document.getElementById('apiResultCount').textContent = '0';
return;
}
document.getElementById('apiResultCount').textContent = apis.length;
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';
document.getElementById('apiResultCount').textContent = '0';
});
}
// 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 (Matches mainApiList.html exactly, card clicks toggle selection)
function createApiCard(api) {
const card = document.createElement('div');
card.className = 's2-api-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="s2-api-card-badge">
<span>${api.apiGroupName || '카테고리'}</span>
</div>
<!-- Checkbox container with visible custom design -->
<label class="s2-checkbox-wrapper">
<input type="checkbox"
name="selectedApis"
value="${api.apiId}"
id="api-${api.apiId}"
class="s2-api-checkbox visually-hidden"
${isSelected ? 'checked' : ''}>
<span class="s2-checkbox-custom"></span>
</label>
<h3 class="s2-api-card-title">${api.apiName || 'API 이름'}</h3>
<p class="s2-api-card-desc">${api.apiSimpleDescription || 'API 설명이 없습니다.'}</p>
<div class="s2-api-card-image">
${mainIconHtml}
</div>
`;
return card;
}
// Attach event listeners to cards
function attachCardEventListeners() {
const apiCards = document.querySelectorAll('.s2-api-card');
apiCards.forEach(card => {
card.addEventListener('click', function(e) {
const checkbox = card.querySelector('.s2-api-checkbox');
if (checkbox) {
checkbox.checked = !checkbox.checked;
updateCardSelection(checkbox);
updateSelectedCount();
}
});
});
// Prevent double toggle when clicking the checkbox wrapper
const checkboxWrappers = document.querySelectorAll('.s2-checkbox-wrapper');
checkboxWrappers.forEach(wrapper => {
wrapper.addEventListener('click', function(e) {
e.stopPropagation(); // Stop click from bubbling to card!
});
// Listen to checkbox change event inside it
const checkbox = wrapper.querySelector('.s2-api-checkbox');
if (checkbox) {
checkbox.addEventListener('change', function() {
updateCardSelection(this);
updateSelectedCount();
});
}
});
}
// Update card visual state
function updateCardSelection(checkbox) {
const card = checkbox.closest('.s2-api-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('.s2-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('.s2-api-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('.s2-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="s2-empty-message">선택된 API가 없습니다.</p>';
return;
}
// Get all checked checkboxes
const checkedBoxes = document.querySelectorAll('.s2-api-checkbox:checked');
checkedBoxes.forEach(function(checkbox) {
const card = checkbox.closest('.s2-api-card');
const apiName = card.querySelector('.s2-api-card-title').textContent;
const apiPill = document.createElement('div');
apiPill.className = 's2-api-pill';
apiPill.innerHTML = `
<span class="s2-api-pill-name">${apiName}</span>
<button type="button" class="s2-api-pill-remove" data-value="${checkbox.value}" aria-label="Remove ${apiName}">✕</button>
`;
modalSelectedList.appendChild(apiPill);
});
// Add remove handlers
document.querySelectorAll('.s2-api-pill-remove').forEach(function(btn) {
btn.addEventListener('click', function() {
const value = this.getAttribute('data-value');
const checkbox = document.querySelector('.s2-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('.s2-api-card');
let visibleCount = 0;
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 = '';
visibleCount++;
} else {
card.style.display = 'none';
}
});
document.getElementById('apiResultCount').textContent = visibleCount;
// Update select all checkbox state after search
updateSelectAllCheckboxState();
});
}
// Category tab 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();
// Load APIs for selected service
loadApis(groupId);
// Clear search when changing category
if (searchInput) {
searchInput.value = '';
}
});
});
// Category Carousel Scroll
const categoryListWrapper = document.getElementById('categoryListWrapper');
const btnPrevCategory = document.getElementById('btnPrevCategory');
const btnNextCategory = document.getElementById('btnNextCategory');
if (categoryListWrapper && btnPrevCategory && btnNextCategory) {
const scrollAmount = 200;
btnPrevCategory.addEventListener('click', function() {
categoryListWrapper.scrollBy({ left: -scrollAmount, behavior: 'smooth' });
});
btnNextCategory.addEventListener('click', function() {
categoryListWrapper.scrollBy({ left: scrollAmount, behavior: 'smooth' });
});
// Toggle buttons visibility/disabled state based on scroll position
function updateCarouselButtons() {
const scrollLeft = categoryListWrapper.scrollLeft;
const maxScrollLeft = categoryListWrapper.scrollWidth - categoryListWrapper.clientWidth;
btnPrevCategory.disabled = scrollLeft <= 0;
btnNextCategory.disabled = scrollLeft >= maxScrollLeft - 1;
}
categoryListWrapper.addEventListener('scroll', updateCarouselButtons);
window.addEventListener('resize', updateCarouselButtons);
// Initial check after loading categories
setTimeout(updateCarouselButtons, 150);
}
// 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('.s2-api-card')).filter(card => card.style.display !== 'none');
visibleCards.forEach(function(card) {
const checkbox = card.querySelector('.s2-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 (Thymeleaf contextPath safe)
form.action = /*[[@{/myapikey/register/step2/save}]]*/ '/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> </body>
</html> </html>
@@ -0,0 +1,480 @@
<!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-inner">
<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>
</div>
</header>
<div class="container service-main">
<!-- Sidebar -->
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('webhookGuide')}"></th:block>
<div class="service-content">
<!-- 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>[마이페이지 &gt; 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=&lt;hex&gt;</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=&lt;hex&gt;</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 &amp;&amp;
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>
</div>
</div>
</section>
</th:block>
</body>
<th:block layout:fragment="contentScript">
</th:block>
</html>
@@ -0,0 +1,72 @@
<!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>
</div>
</section>
<th:block layout:fragment="contentFragment">
<div class="signup-guide-v2 figma-register-wrapper">
<div class="service-main app-management-layout">
<!-- Sidebar -->
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('webhook')}"></th:block>
<!-- Content Area -->
<div class="app-management-content">
<div class="step1-wrap">
<!-- Title -->
<h2 class="s1-title">Webhook 관리</h2>
<!-- Empty Card -->
<div class="s1-form-card">
<div class="webhook-empty">
<div class="empty-icon">🔔</div>
<h3>등록된 Webhook이 없습니다</h3>
<p>API 서비스의 점검·지연·장애 알림을 받을 Webhook을 신청해보세요.</p>
<p class="field-help" sec:authorize="!hasRole('ROLE_API_KEY_REQUEST')">
Webhook 신청은 법인 관리자만 가능합니다. 기관 관리자에게 문의하세요.
</p>
</div>
</div>
<!-- 액션 -->
<div class="s1-actions" sec:authorize="hasRole('ROLE_API_KEY_REQUEST')">
<button type="button" class="s1-btn-next" id="requestWebhook">Webhook 신청</button>
</div>
<p class="webhook-guide-link">
<a th:href="@{/service/webhook-dev-guide}">
📘 웹훅 개발가이드 — 수신·서명검증·응답 규칙 보러가기
</a>
</p>
</div><!-- /step1-wrap -->
</div>
</div>
</div>
</th:block>
<th:block layout:fragment="contentScript">
<script th:inline="javascript">
document.addEventListener('DOMContentLoaded', function () {
var btn = document.getElementById('requestWebhook');
if (btn) {
btn.addEventListener('click', function () {
window.location.href = '/webhook/register/step1?clear=true';
});
}
});
</script>
</th:block>
</body>
</html>
@@ -0,0 +1,190 @@
<!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>
</div>
</section>
<th:block layout:fragment="contentFragment">
<div class="signup-guide-v2 figma-register-wrapper">
<div class="service-main app-management-layout">
<!-- Sidebar -->
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('webhook')}"></th:block>
<!-- Content Area -->
<div class="app-management-content">
<div class="step1-wrap">
<!-- Title -->
<h2 class="s1-title">Webhook 관리</h2>
<!-- Registered Webhook Card -->
<div class="s1-form-card" th:object="${webhook}">
<div class="webhook-card-head">
<h3>등록된 Webhook</h3>
<span class="webhook-created" th:if="*{createdDate != null and #strings.length(createdDate) >= 8}"
th:text="|등록일 ${#strings.substring(webhook.createdDate,0,4)}-${#strings.substring(webhook.createdDate,4,6)}-${#strings.substring(webhook.createdDate,6,8)}|">등록일</span>
</div>
<div class="webhook-card-row">
<span class="row-label">수신 URL</span>
<span class="row-value" th:text="*{targetUrl}">https://...</span>
</div>
<div class="webhook-card-row">
<span class="row-label">알림 이벤트</span>
<span class="row-value">
<span class="eventtype-badge" th:each="et : *{eventTypes}" th:text="${et.name}">이벤트</span>
</span>
</div>
<div class="webhook-card-row">
<span class="row-label">대상 API</span>
<span class="row-value">
<span class="api-badge" th:each="apiId : *{apiIds}" th:text="${apiId}">API</span>
</span>
</div>
<div class="webhook-card-row">
<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>
<!-- 액션 -->
<div class="s1-actions" sec:authorize="hasRole('ROLE_API_KEY_REQUEST')">
<button type="button" class="btn-webhook-danger" id="btnDeleteWebhook">삭제</button>
<a class="s1-btn-next" th:href="@{/webhook/modify/step1}">수정</a>
</div>
<p class="webhook-guide-link">
<a th:href="@{/service/webhook-dev-guide}">
📘 웹훅 개발가이드 — 수신·서명검증·응답 규칙 보러가기
</a>
</p>
</div><!-- /step1-wrap -->
</div>
</div>
</div>
<!-- 비밀번호 재인증 모달 -->
<div class="webhook-modal" id="pwModal" style="display:none;">
<div class="webhook-modal-box">
<h4 id="pwModalTitle">비밀번호 확인</h4>
<p id="pwModalDesc">계속하려면 비밀번호를 입력하세요.</p>
<input type="password" id="pwModalInput" placeholder="비밀번호" autocomplete="current-password">
<p class="field-error" id="pwModalError" style="display:none;"></p>
<div class="webhook-modal-actions">
<button type="button" class="btn-webhook-cancel" id="pwModalCancel">취소</button>
<button type="button" class="btn-webhook-next" id="pwModalConfirm">확인</button>
</div>
</div>
</div>
</th:block>
<th:block layout:fragment="contentScript">
<script th:inline="javascript">
document.addEventListener('DOMContentLoaded', function () {
var csrfToken = document.querySelector('meta[name="_csrf"]');
var csrfHeaderMeta = document.querySelector('meta[name="_csrf_header"]');
var CSRF_TOKEN = csrfToken ? csrfToken.getAttribute('content') : '';
var CSRF_HEADER = csrfHeaderMeta ? csrfHeaderMeta.getAttribute('content') : 'X-XSRF-TOKEN';
var modal = document.getElementById('pwModal');
var input = document.getElementById('pwModalInput');
var errorEl = document.getElementById('pwModalError');
var titleEl = document.getElementById('pwModalTitle');
var descEl = document.getElementById('pwModalDesc');
var pendingAction = null;
function openModal(title, desc, action) {
titleEl.textContent = title;
descEl.textContent = desc;
input.value = '';
errorEl.style.display = 'none';
pendingAction = action;
modal.style.display = 'flex';
input.focus();
}
function closeModal() { modal.style.display = 'none'; pendingAction = null; }
document.getElementById('pwModalCancel').addEventListener('click', closeModal);
document.getElementById('pwModalConfirm').addEventListener('click', function () {
if (pendingAction) { pendingAction(input.value); }
});
input.addEventListener('keyup', function (e) {
if (e.key === 'Enter' && pendingAction) { pendingAction(input.value); }
});
function post(url, password) {
var headers = { 'Content-Type': 'application/x-www-form-urlencoded' };
headers[CSRF_HEADER] = CSRF_TOKEN;
return fetch(url, {
method: 'POST',
headers: headers,
body: 'password=' + encodeURIComponent(password)
}).then(function (r) { return r.json(); });
}
function showError(msg) { errorEl.textContent = msg; errorEl.style.display = 'block'; }
// Secret 조회
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) {
document.getElementById('secretMasked').textContent = res.secret;
closeModal();
} else { showError(res.message || '실패했습니다.'); }
}).catch(function () { showError('요청 처리 중 오류가 발생했습니다.'); });
});
});
// Secret 재발급
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이 재발급되었습니다.\n반드시 수신 서버의 서명검증 Secret을 새 값으로 교체하세요.\n교체 전까지 Webhook 서명 검증이 실패합니다.');
} else { showError(res.message || '실패했습니다.'); }
}).catch(function () { showError('요청 처리 중 오류가 발생했습니다.'); });
});
});
// 삭제
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'; }
else { showError(res.message || '실패했습니다.'); }
}).catch(function () { showError('요청 처리 중 오류가 발생했습니다.'); });
});
});
});
</script>
</th:block>
</body>
</html>
@@ -0,0 +1,112 @@
<!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>
<section layout:fragment="title">
<div class="page-title-banner">
<img th:src="@{/img/img_title_bg.png}" class="title-image">
<h1>Webhook 관리</h1>
</div>
</section>
<th:block layout:fragment="contentFragment">
<div class="signup-guide-v2 figma-register-wrapper">
<div class="service-main app-management-layout">
<!-- Sidebar -->
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('webhook')}"></th:block>
<!-- Content Area -->
<div class="app-management-content">
<div class="step1-wrap">
<!-- Title -->
<h2 class="s1-title">Webhook 수정</h2>
<!-- Progress Steps Card -->
<div class="s1-progress-card">
<div class="s1-steps">
<!-- Step 1 Active: 기본 정보 수정 -->
<div class="s1-step s1-step--active">
<div class="s1-step-circle">
<img th:src="@{/img/apikey_step1.png}" alt="기본 정보 수정" width="36" height="36">
</div>
<span class="s1-step-num">1단계</span>
<span class="s1-step-name">기본 정보 수정</span>
</div>
<div class="s1-step-line"></div>
<!-- Step 2: API 선택 -->
<div class="s1-step">
<div class="s1-step-circle">
<img th:src="@{/img/apikey_step2.png}" alt="API 선택" width="36" height="36">
</div>
<span class="s1-step-num">2단계</span>
<span class="s1-step-name">API 선택</span>
</div>
<div class="s1-step-line"></div>
<!-- Step 3: 수정 완료 -->
<div class="s1-step">
<div class="s1-step-circle">
<img th:src="@{/img/apikey_step3.png}" alt="수정 완료" width="36" height="36">
</div>
<span class="s1-step-num">3단계</span>
<span class="s1-step-name">수정 완료</span>
</div>
</div>
</div>
<!-- Error -->
<div th:if="${error}" class="s1-alert">
<span th:text="${error}"></span>
</div>
<!-- Form Card -->
<div class="s1-form-card">
<form id="webhookStep1Form" method="post" th:action="@{/webhook/modify/step1}"
th:object="${webhookModification}">
<!-- Webhook 수신 URL -->
<div class="s1-field">
<label class="s1-label">Webhook 수신 URL <span class="s1-required">*</span></label>
<input type="text" id="targetUrl" th:field="*{targetUrl}" class="s1-input"
placeholder="https://example.com/webhook" autocomplete="off" maxlength="255">
<p class="field-error" th:if="${#fields.hasErrors('targetUrl')}" th:errors="*{targetUrl}">URL 오류</p>
<p class="field-help">이벤트 발생 시 이 URL로 서명된 POST 요청이 전송됩니다.</p>
</div>
<!-- 알림 이벤트 -->
<div class="s1-field">
<label class="s1-label">알림 받을 이벤트 <span class="s1-required">*</span></label>
<div class="webhook-eventtype-list">
<label class="eventtype-item" th:each="et : ${eventTypes}">
<input type="checkbox" name="eventTypes" th:value="${et.code}"
th:checked="${#lists.contains(webhookModification.eventTypes, et.code)}">
<span class="eventtype-name" th:text="${et.name}">이벤트명</span>
<span class="eventtype-code" th:text="${et.code}">CODE</span>
</label>
</div>
<p class="field-error" th:if="${#fields.hasErrors('eventTypes')}" th:errors="*{eventTypes}">이벤트 오류</p>
</div>
</form>
</div>
<!-- 액션 -->
<div class="s1-actions">
<a class="btn-webhook-cancel" th:href="@{/webhook/modify/cancel}">취소</a>
<button type="submit" form="webhookStep1Form" class="s1-btn-next">다음</button>
</div>
</div><!-- /step1-wrap -->
</div>
</div>
</div>
</th:block>
</body>
</html>
@@ -0,0 +1,94 @@
<!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>
</div>
</section>
<th:block layout:fragment="contentFragment">
<div class="signup-guide-v2 figma-register-wrapper">
<div class="service-main app-management-layout">
<!-- Sidebar -->
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('webhook')}"></th:block>
<!-- Content Area -->
<div class="app-management-content">
<div class="step2-wrap">
<!-- Title -->
<h2 class="s1-title">Webhook 수정</h2>
<!-- Progress Steps Card -->
<div class="s1-progress-card">
<div class="s1-steps">
<!-- Step 1: 기본 정보 수정 -->
<div class="s1-step">
<div class="s1-step-circle">
<img th:src="@{/img/apikey_step1.png}" alt="기본 정보 수정" width="36" height="36">
</div>
<span class="s1-step-num">1단계</span>
<span class="s1-step-name">기본 정보 수정</span>
</div>
<div class="s1-step-line"></div>
<!-- Step 2 Active: API 선택 -->
<div class="s1-step s1-step--active">
<div class="s1-step-circle">
<img th:src="@{/img/apikey_step2.png}" alt="API 선택" width="36" height="36">
</div>
<span class="s1-step-num">2단계</span>
<span class="s1-step-name">API 선택</span>
</div>
<div class="s1-step-line"></div>
<!-- Step 3: 수정 완료 -->
<div class="s1-step">
<div class="s1-step-circle">
<img th:src="@{/img/apikey_step3.png}" alt="수정 완료" width="36" height="36">
</div>
<span class="s1-step-num">3단계</span>
<span class="s1-step-name">수정 완료</span>
</div>
</div>
</div>
<!-- Error -->
<div th:if="${error}" class="s1-alert">
<span th:text="${error}"></span>
</div>
<!-- API 선택 공용 모듈 -->
<th:block th:replace="~{fragment/api_selector :: apiSelector(${apiServices}, ${webhookModification.selectedApis}, '/webhook/modify/step2', '/webhook/modify/step2/save')}"/>
<!-- Bottom Navigation Actions -->
<div class="s2-actions">
<button type="button" id="btnPrevStep" class="s2-btn-prev">
이전
</button>
<button type="submit" form="apiSelectorForm" class="s2-btn-save">
수정 완료
</button>
</div>
</div><!-- /step2-wrap -->
</div>
</div>
</div>
</th:block>
<!-- 플로팅 카트/모달: body 직속 렌더 (wrapper transform 영향 회피) -->
<section layout:fragment="pagePopups">
<th:block th:replace="~{fragment/api_selector :: apiSelectorPopups}"/>
</section>
</body>
</html>
@@ -0,0 +1,82 @@
<!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>
<section layout:fragment="title">
<div class="page-title-banner">
<img th:src="@{/img/img_title_bg.png}" class="title-image">
<h1>Webhook 관리</h1>
</div>
</section>
<th:block layout:fragment="contentFragment">
<div class="signup-guide-v2 figma-register-wrapper">
<div class="service-main app-management-layout">
<!-- Sidebar -->
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('webhook')}"></th:block>
<!-- Content Area -->
<div class="app-management-content">
<div class="step3-wrap">
<!-- Title -->
<h2 class="s1-title">Webhook 수정</h2>
<!-- Progress Steps Card -->
<div class="s1-progress-card">
<div class="s1-steps">
<div class="s1-step">
<div class="s1-step-circle">
<img th:src="@{/img/apikey_step1.png}" alt="기본 정보 수정" width="36" height="36">
</div>
<span class="s1-step-num">1단계</span>
<span class="s1-step-name">기본 정보 수정</span>
</div>
<div class="s1-step-line"></div>
<div class="s1-step">
<div class="s1-step-circle">
<img th:src="@{/img/apikey_step2.png}" alt="API 선택" width="36" height="36">
</div>
<span class="s1-step-num">2단계</span>
<span class="s1-step-name">API 선택</span>
</div>
<div class="s1-step-line"></div>
<!-- Step 3 Active: 수정 완료 -->
<div class="s1-step s1-step--active">
<div class="s1-step-circle">
<img th:src="@{/img/apikey_step3.png}" alt="수정 완료" width="36" height="36">
</div>
<span class="s1-step-num">3단계</span>
<span class="s1-step-name">수정 완료</span>
</div>
</div>
</div>
<!-- Complete Card -->
<div class="s1-form-card">
<div class="webhook-complete">
<div class="complete-icon"></div>
<h3>Webhook 설정이 수정되었습니다</h3>
<p class="field-help">Secret Key는 변경되지 않았습니다.</p>
</div>
</div>
<!-- 액션 -->
<div class="s1-actions">
<a class="s1-btn-next" th:href="@{/webhook}">완료</a>
</div>
</div><!-- /step3-wrap -->
</div>
</div>
</div>
</th:block>
</body>
</html>
@@ -0,0 +1,112 @@
<!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>
<section layout:fragment="title">
<div class="page-title-banner">
<img th:src="@{/img/img_title_bg.png}" class="title-image">
<h1>Webhook 관리</h1>
</div>
</section>
<th:block layout:fragment="contentFragment">
<div class="signup-guide-v2 figma-register-wrapper">
<div class="service-main app-management-layout">
<!-- Sidebar -->
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('webhook')}"></th:block>
<!-- Content Area -->
<div class="app-management-content">
<div class="step1-wrap">
<!-- Title -->
<h2 class="s1-title">Webhook 신청</h2>
<!-- Progress Steps Card -->
<div class="s1-progress-card">
<div class="s1-steps">
<!-- Step 1 Active: 기본 정보 입력 -->
<div class="s1-step s1-step--active">
<div class="s1-step-circle">
<img th:src="@{/img/apikey_step1.png}" alt="기본 정보 입력" width="36" height="36">
</div>
<span class="s1-step-num">1단계</span>
<span class="s1-step-name">기본 정보 입력</span>
</div>
<div class="s1-step-line"></div>
<!-- Step 2: API 선택 -->
<div class="s1-step">
<div class="s1-step-circle">
<img th:src="@{/img/apikey_step2.png}" alt="API 선택" width="36" height="36">
</div>
<span class="s1-step-num">2단계</span>
<span class="s1-step-name">API 선택</span>
</div>
<div class="s1-step-line"></div>
<!-- Step 3: 신청 완료 -->
<div class="s1-step">
<div class="s1-step-circle">
<img th:src="@{/img/apikey_step3.png}" alt="신청 완료" width="36" height="36">
</div>
<span class="s1-step-num">3단계</span>
<span class="s1-step-name">신청 완료</span>
</div>
</div>
</div>
<!-- Error -->
<div th:if="${error}" class="s1-alert">
<span th:text="${error}"></span>
</div>
<!-- Form Card -->
<div class="s1-form-card">
<form id="webhookStep1Form" method="post" th:action="@{/webhook/register/step1}"
th:object="${webhookRegistration}">
<!-- Webhook 수신 URL -->
<div class="s1-field">
<label class="s1-label">Webhook 수신 URL <span class="s1-required">*</span></label>
<input type="text" id="targetUrl" th:field="*{targetUrl}" class="s1-input"
placeholder="https://example.com/webhook" autocomplete="off" maxlength="255">
<p class="field-error" th:if="${#fields.hasErrors('targetUrl')}" th:errors="*{targetUrl}">URL 오류</p>
<p class="field-help">이벤트 발생 시 이 URL로 서명된 POST 요청이 전송됩니다.</p>
</div>
<!-- 알림 이벤트 -->
<div class="s1-field">
<label class="s1-label">알림 받을 이벤트 <span class="s1-required">*</span></label>
<div class="webhook-eventtype-list">
<label class="eventtype-item" th:each="et : ${eventTypes}">
<input type="checkbox" name="eventTypes" th:value="${et.code}"
th:checked="${#lists.contains(webhookRegistration.eventTypes, et.code)}">
<span class="eventtype-name" th:text="${et.name}">이벤트명</span>
<span class="eventtype-code" th:text="${et.code}">CODE</span>
</label>
</div>
<p class="field-error" th:if="${#fields.hasErrors('eventTypes')}" th:errors="*{eventTypes}">이벤트 오류</p>
</div>
</form>
</div>
<!-- 액션 -->
<div class="s1-actions">
<a class="btn-webhook-cancel" th:href="@{/webhook/register/cancel}">취소</a>
<button type="submit" form="webhookStep1Form" class="s1-btn-next">다음</button>
</div>
</div><!-- /step1-wrap -->
</div>
</div>
</div>
</th:block>
</body>
</html>
@@ -0,0 +1,94 @@
<!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>
</div>
</section>
<th:block layout:fragment="contentFragment">
<div class="signup-guide-v2 figma-register-wrapper">
<div class="service-main app-management-layout">
<!-- Sidebar -->
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('webhook')}"></th:block>
<!-- Content Area -->
<div class="app-management-content">
<div class="step2-wrap">
<!-- Title -->
<h2 class="s1-title">Webhook 신청</h2>
<!-- Progress Steps Card -->
<div class="s1-progress-card">
<div class="s1-steps">
<!-- Step 1: 기본 정보 입력 -->
<div class="s1-step">
<div class="s1-step-circle">
<img th:src="@{/img/apikey_step1.png}" alt="기본 정보 입력" width="36" height="36">
</div>
<span class="s1-step-num">1단계</span>
<span class="s1-step-name">기본 정보 입력</span>
</div>
<div class="s1-step-line"></div>
<!-- Step 2 Active: API 선택 -->
<div class="s1-step s1-step--active">
<div class="s1-step-circle">
<img th:src="@{/img/apikey_step2.png}" alt="API 선택" width="36" height="36">
</div>
<span class="s1-step-num">2단계</span>
<span class="s1-step-name">API 선택</span>
</div>
<div class="s1-step-line"></div>
<!-- Step 3: 신청 완료 -->
<div class="s1-step">
<div class="s1-step-circle">
<img th:src="@{/img/apikey_step3.png}" alt="신청 완료" width="36" height="36">
</div>
<span class="s1-step-num">3단계</span>
<span class="s1-step-name">신청 완료</span>
</div>
</div>
</div>
<!-- Error -->
<div th:if="${error}" class="s1-alert">
<span th:text="${error}"></span>
</div>
<!-- API 선택 공용 모듈 -->
<th:block th:replace="~{fragment/api_selector :: apiSelector(${apiServices}, ${webhookRegistration.selectedApis}, '/webhook/register/step2', '/webhook/register/step2/save')}"/>
<!-- Bottom Navigation Actions -->
<div class="s2-actions">
<button type="button" id="btnPrevStep" class="s2-btn-prev">
이전
</button>
<button type="submit" form="apiSelectorForm" class="s2-btn-save">
신청 완료
</button>
</div>
</div><!-- /step2-wrap -->
</div>
</div>
</div>
</th:block>
<!-- 플로팅 카트/모달: body 직속 렌더 (wrapper transform 영향 회피) -->
<section layout:fragment="pagePopups">
<th:block th:replace="~{fragment/api_selector :: apiSelectorPopups}"/>
</section>
</body>
</html>
@@ -0,0 +1,82 @@
<!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>
<section layout:fragment="title">
<div class="page-title-banner">
<img th:src="@{/img/img_title_bg.png}" class="title-image">
<h1>Webhook 관리</h1>
</div>
</section>
<th:block layout:fragment="contentFragment">
<div class="signup-guide-v2 figma-register-wrapper">
<div class="service-main app-management-layout">
<!-- Sidebar -->
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('webhook')}"></th:block>
<!-- Content Area -->
<div class="app-management-content">
<div class="step3-wrap">
<!-- Title -->
<h2 class="s1-title">Webhook 신청</h2>
<!-- Progress Steps Card -->
<div class="s1-progress-card">
<div class="s1-steps">
<div class="s1-step">
<div class="s1-step-circle">
<img th:src="@{/img/apikey_step1.png}" alt="기본 정보 입력" width="36" height="36">
</div>
<span class="s1-step-num">1단계</span>
<span class="s1-step-name">기본 정보 입력</span>
</div>
<div class="s1-step-line"></div>
<div class="s1-step">
<div class="s1-step-circle">
<img th:src="@{/img/apikey_step2.png}" alt="API 선택" width="36" height="36">
</div>
<span class="s1-step-num">2단계</span>
<span class="s1-step-name">API 선택</span>
</div>
<div class="s1-step-line"></div>
<!-- Step 3 Active: 신청 완료 -->
<div class="s1-step s1-step--active">
<div class="s1-step-circle">
<img th:src="@{/img/apikey_step3.png}" alt="신청 완료" width="36" height="36">
</div>
<span class="s1-step-num">3단계</span>
<span class="s1-step-name">신청 완료</span>
</div>
</div>
</div>
<!-- Complete Card -->
<div class="s1-form-card">
<div class="webhook-complete">
<div class="complete-icon"></div>
<h3>Webhook이 등록되었습니다</h3>
<p class="field-help">Secret Key는 [Webhook 관리]에서 비밀번호 확인 후 조회할 수 있습니다.</p>
</div>
</div>
<!-- 액션 -->
<div class="s1-actions">
<a class="s1-btn-next" th:href="@{/webhook}">완료</a>
</div>
</div><!-- /step3-wrap -->
</div>
</div>
</div>
</th:block>
</body>
</html>
@@ -0,0 +1,140 @@
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
<body>
<!--
API 선택 공용 모듈 (figma s2 디자인: 카테고리 캐러셀 탭 + 카드그리드 + 플로팅 카트 + 선택목록 모달)
사용처: 앱(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')
호출 (contentFragment 안):
<th:block th:replace="~{fragment/api_selector :: apiSelector(${apiServices}, ${webhookRegistration.selectedApis}, '/webhook/register/step2', '/webhook/register/step2/save')}"/>
호출 (pagePopups fragment 안 — 필수! 플로팅 카트/모달은 wrapper transform 영향을 피해 body 직속 렌더):
<section layout:fragment="pagePopups">
<th:block th:replace="~{fragment/api_selector :: apiSelectorPopups}"/>
</section>
계약:
- 폼 id 고정 "apiSelectorForm" — 제출 버튼은 form="apiSelectorForm" 으로 연결.
- "이전" 버튼은 호출 페이지에 id="btnPrevStep" — 모듈 JS가 data-save-action 경로로 저장 POST 후 step1 복귀.
- 추가 hidden 필드는 호출 페이지에서 form="apiSelectorForm" 속성으로 주입(예: apikey 수정 clientId).
- API 목록: GET /apis/for_request (ROLE_API_KEY_REQUEST) AJAX.
- 스타일: design s2-* (_apikey-register.scss step2 재작업분) 재사용.
-->
<th:block th:fragment="apiSelector(apiServices, selectedApis, formAction, saveAction)">
<!-- Category Carousel Tab Container -->
<div class="s2-category-carousel-container">
<button type="button" class="s2-carousel-btn s2-carousel-btn--prev" id="btnPrevCategory" aria-label="이전 카테고리">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="15 18 9 12 15 6"></polyline>
</svg>
</button>
<div class="s2-category-list-wrapper" id="categoryListWrapper">
<div class="s2-category-list" id="categoryList">
<button type="button" class="s2-category-tab active" data-group="">
전체
</button>
<button type="button" class="s2-category-tab" th:each="service : ${apiServices}" th:data-group="${service.id}" th:text="${service.groupName}">
서비스명
</button>
</div>
</div>
<button type="button" class="s2-carousel-btn s2-carousel-btn--next" id="btnNextCategory" aria-label="다음 카테고리">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="9 18 15 12 9 6"></polyline>
</svg>
</button>
</div>
<!-- Main Content Area -->
<div class="s2-selection-container">
<main class="s2-content-area">
<form id="apiSelectorForm" method="post" th:action="@{${formAction}}" class="s2-form"
th:attr="data-save-action=@{${saveAction}}">
<!-- Header filter: Search and Select All -->
<div class="s2-filter-header">
<div class="s2-select-all" id="selectAllWrapper" style="display: none;">
<label class="s2-select-all-label">
<input type="checkbox" id="selectAllCheckbox" class="visually-hidden">
<span class="s2-checkbox-custom"></span>
<span id="selectAllText">전체 선택</span>
</label>
</div>
<div class="s2-result-count">
<strong id="apiResultCount">0</strong>
</div>
<div class="s2-search-box">
<input type="text" id="apiSearch" class="s2-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="s2-cards-grid" id="apiCardGrid">
<!-- Loading state -->
<div class="s2-loading" id="loadingState" style="grid-column: 1 / -1;">
<div class="s2-spinner"></div>
<p>API 목록을 불러오는 중...</p>
</div>
<!-- Empty state -->
<div class="s2-empty" id="emptyState" style="display: none; grid-column: 1 / -1;">
<div class="s2-empty-icon">📦</div>
<h3>API를 선택해주세요</h3>
<p>상단 카테고리에서 서비스를 선택하면 해당 API 목록이 표시됩니다.</p>
</div>
</div>
</form>
</main>
</div>
<!-- 모듈 초기 데이터 + 스크립트 -->
<script th:inline="javascript">
window.API_SELECTOR_SELECTED = /*[[${selectedApis}]]*/ [];
window.API_SELECTOR_LIST_URL = /*[[@{/apis/for_request}]]*/ '/apis/for_request';
</script>
<script th:src="@{/js/api-selector.js}"></script>
</th:block>
<!-- 플로팅 카트 + 선택목록 모달 (pagePopups 슬롯에서 호출 — body 직속 렌더) -->
<th:block th:fragment="apiSelectorPopups">
<!-- Floating Cart Button -->
<button type="button" class="s2-floating-cart" id="floatingCartBtn" style="display: none;">
<span class="s2-cart-label">선택된 API</span>
<span class="s2-cart-badge" id="cartBadge">
<span class="s2-cart-count">0</span>
<span class="s2-cart-unit"></span>
</span>
</button>
<!-- Selected APIs Modal -->
<div class="s2-modal" id="selectedApisModal" style="display: none;">
<div class="s2-modal-backdrop" id="modalOverlay"></div>
<div class="s2-modal-dialog">
<div class="s2-modal-header">
<h3 class="s2-modal-title">선택된 API 목록</h3>
<button type="button" class="s2-modal-close" id="modalCloseBtn"></button>
</div>
<div class="s2-modal-body">
<div class="s2-selected-list" id="modalSelectedList">
<!-- Dynamically populated -->
</div>
</div>
<div class="s2-modal-footer">
<button type="button" class="s2-btn-close-modal" id="modalCancelBtn">닫기</button>
</div>
</div>
</div>
</th:block>
</body>
</html>
@@ -44,6 +44,7 @@
<li><a th:href="@{/service/intro}">API 포탈 소개</a></li> <li><a th:href="@{/service/intro}">API 포탈 소개</a></li>
<li><a th:href="@{/service/guide}">회원가입 안내</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/oauth2-guide}">OAuth2 개발가이드</a></li>
<li><a th:href="@{/service/webhook-dev-guide}">웹훅 개발가이드</a></li>
</ul> </ul>
</li> </li>
<li><a href="/apis" class="nav-link">오픈 API</a></li> <li><a href="/apis" class="nav-link">오픈 API</a></li>
@@ -76,16 +77,6 @@
<!-- Logout State (Authenticated) --> <!-- Logout State (Authenticated) -->
<div class="auth-group authenticated" sec:authorize="isAuthenticated()"> <div class="auth-group authenticated" sec:authorize="isAuthenticated()">
<div class="header-user-info"> <div class="header-user-info">
<span class="session-timer" id="sessionTimer" title="남은 세션 시간">
<i class="fas fa-clock session-timer-icon"></i>
<span class="session-timer-text" id="sessionRemainingTime">--:--</span>
</span>
<!-- 세션 유지(타임아웃 무시) 체크박스: 비운영 + property 활성 시에만 렌더 (prod 미노출) -->
<label class="session-keepalive" th:if="${sessionKeepAliveAllowed}" title="세션 자동 유지 (비운영 전용)">
<input type="checkbox" id="sessionKeepAliveToggle">
<span>세션 유지</span>
</label>
<span class="divider"></span>
<div class="user-identity"> <div class="user-identity">
<img th:src="@{/img/user_icon.svg}" alt="User" class="user-icon"> <img th:src="@{/img/user_icon.svg}" alt="User" class="user-icon">
<span class="user-name">[[${#authentication.principal.userName}]]님</span> <span class="user-name">[[${#authentication.principal.userName}]]님</span>
@@ -102,6 +93,7 @@
</li> </li>
<li sec:authorize="hasRole('ROLE_APP')"> <li sec:authorize="hasRole('ROLE_APP')">
<a th:href="@{/myapikey}"><i class="fas fa-key"></i>인증 키 관리</a> <a th:href="@{/myapikey}"><i class="fas fa-key"></i>인증 키 관리</a>
<a th:href="@{/webhook}"><i class="fas fa-bell"></i>Webhook 관리</a>
<a th:href="@{/statistics/api}"><i class="fas fa-chart-bar"></i>이용 통계</a> <a th:href="@{/statistics/api}"><i class="fas fa-chart-bar"></i>이용 통계</a>
</li> </li>
<li><a th:href="@{/mypage}"><i class="fas fa-user-circle"></i>내 정보 관리</a></li> <li><a th:href="@{/mypage}"><i class="fas fa-user-circle"></i>내 정보 관리</a></li>
@@ -140,6 +132,19 @@
</div> </div>
</header> </header>
<!-- 우측 상단 Float 세션 타이머 (인증 사용자, PC 전용). 모바일에서는 CSS로 숨김 -->
<div class="session-float" sec:authorize="isAuthenticated()">
<span class="session-timer" id="sessionTimer" title="남은 세션 시간">
<i class="fas fa-clock session-timer-icon"></i>
<span class="session-timer-text" id="sessionRemainingTime">--:--</span>
</span>
<!-- 세션 유지(타임아웃 무시) 체크박스: 비운영 + property 활성 시에만 렌더 (prod 미노출) -->
<label class="session-keepalive" th:if="${sessionKeepAliveAllowed}" title="세션 자동 유지 (비운영 전용)">
<input type="checkbox" id="sessionKeepAliveToggle">
<span>세션 유지</span>
</label>
</div>
<!-- Mobile Drawer/Modal --> <!-- Mobile Drawer/Modal -->
<div class="mobile-drawer" id="mobileDrawer"> <div class="mobile-drawer" id="mobileDrawer">
<div class="drawer-overlay" id="drawerOverlay"></div> <div class="drawer-overlay" id="drawerOverlay"></div>
@@ -202,6 +207,7 @@
<li><a th:href="@{/service/intro}">API 포탈 소개</a></li> <li><a th:href="@{/service/intro}">API 포탈 소개</a></li>
<li><a th:href="@{/service/guide}">회원가입 안내</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/oauth2-guide}">OAuth2 개발가이드</a></li>
<li><a th:href="@{/service/webhook-dev-guide}">웹훅 개발가이드</a></li>
</ul> </ul>
</li> </li>
@@ -244,8 +250,8 @@
<ul class="drawer-submenu"> <ul class="drawer-submenu">
<li sec:authorize="hasRole('ROLE_USER_MANAGER')"><a th:href="@{/users}">이용자 관리</a></li> <li sec:authorize="hasRole('ROLE_USER_MANAGER')"><a th:href="@{/users}">이용자 관리</a></li>
<li sec:authorize="hasRole('ROLE_APP')"><a th:href="@{/myapikey}">인증 키 관리</a></li> <li sec:authorize="hasRole('ROLE_APP')"><a th:href="@{/myapikey}">인증 키 관리</a></li>
<li sec:authorize="hasRole('ROLE_APP')"><a th:href="@{/webhook}">Webhook 관리</a></li>
<li sec:authorize="hasRole('ROLE_APP')"><a th:href="@{/statistics/api}">이용 통계</a></li> <li sec:authorize="hasRole('ROLE_APP')"><a th:href="@{/statistics/api}">이용 통계</a></li>
<li sec:authorize="hasRole('ROLE_APP')"><a th:href="@{/commission/manage}">과금 관리</a></li>
<li><a th:href="@{/mypage}">내 정보 관리</a></li> <li><a th:href="@{/mypage}">내 정보 관리</a></li>
<li><a th:href="@{/change_password}">비밀번호 변경</a></li> <li><a th:href="@{/change_password}">비밀번호 변경</a></li>
</ul> </ul>
@@ -5,7 +5,7 @@
<aside class="service-sidebar" th:fragment="sidebar(activeMenu)"> <aside class="service-sidebar" th:fragment="sidebar(activeMenu)">
<nav class="service-nav"> <nav class="service-nav">
<!-- 서비스 소개 그룹 (Service) --> <!-- 서비스 소개 그룹 (Service) -->
<th:block th:if="${activeMenu == 'intro' or activeMenu == 'guide' or activeMenu == 'oauth2'}"> <th:block th:if="${activeMenu == 'intro' or activeMenu == 'guide' or activeMenu == 'oauth2' or activeMenu == 'webhookGuide'}">
<a th:href="@{/service/intro}" <a th:href="@{/service/intro}"
th:classappend="${activeMenu == 'intro'} ? 'service-nav__item--active' : ''" th:classappend="${activeMenu == 'intro'} ? 'service-nav__item--active' : ''"
class="service-nav__item">API 포탈 소개</a> class="service-nav__item">API 포탈 소개</a>
@@ -15,6 +15,9 @@
<a th:href="@{/service/oauth2-guide}" <a th:href="@{/service/oauth2-guide}"
th:classappend="${activeMenu == 'oauth2'} ? 'service-nav__item--active' : ''" th:classappend="${activeMenu == 'oauth2'} ? 'service-nav__item--active' : ''"
class="service-nav__item">OAuth2 개발가이드</a> class="service-nav__item">OAuth2 개발가이드</a>
<a th:href="@{/service/webhook-dev-guide}"
th:classappend="${activeMenu == 'webhookGuide'} ? 'service-nav__item--active' : ''"
class="service-nav__item">웹훅 개발가이드</a>
</th:block> </th:block>
<!-- 고객지원 그룹 (Customer Support) --> <!-- 고객지원 그룹 (Customer Support) -->
@@ -34,7 +37,7 @@
</th:block> </th:block>
<!-- 마이페이지 그룹 (My Page) --> <!-- 마이페이지 그룹 (My Page) -->
<th:block th:if="${activeMenu == 'users' or activeMenu == 'apiKey' or activeMenu == 'statistics' or activeMenu == 'profile' or activeMenu == 'password'}"> <th:block th:if="${activeMenu == 'users' or activeMenu == 'apiKey' or activeMenu == 'statistics' or activeMenu == 'webhook' or activeMenu == 'profile' or activeMenu == 'password'}">
<!-- Profile Section --> <!-- Profile Section -->
<div class="service-sidebar__profile"> <div class="service-sidebar__profile">
<div class="avatar"> <div class="avatar">
@@ -56,6 +59,11 @@
th:classappend="${activeMenu == 'apiKey'} ? 'service-nav__item--active' : ''" th:classappend="${activeMenu == 'apiKey'} ? 'service-nav__item--active' : ''"
class="service-nav__item">인증 키 관리</a> class="service-nav__item">인증 키 관리</a>
<a th:href="@{/webhook}"
th:classappend="${activeMenu == 'webhook'} ? 'service-nav__item--active' : ''"
class="service-nav__item"
sec:authorize="hasRole('ROLE_APP')">Webhook 관리</a>
<a th:href="@{/statistics/api}" <a th:href="@{/statistics/api}"
th:classappend="${activeMenu == 'statistics'} ? 'service-nav__item--active' : ''" th:classappend="${activeMenu == 'statistics'} ? 'service-nav__item--active' : ''"
class="service-nav__item" class="service-nav__item"