Webhook 신청/관리 및 EventType 제공 기능 추가:

- Webhook 신청/수정 3단계 UI 및 관련 서비스 구현
- TSEAIRM28 공통코드 기반 EventType 조회 기능 추가
- 신규/수정 세션 관리 및 비밀번호 재인증 로직 포함
This commit is contained in:
Rinjae
2026-07-20 17:36:17 +09:00
parent bdb249b74b
commit ba00b80bfe
38 changed files with 2429 additions and 3 deletions
+21
View File
@@ -388,6 +388,27 @@ page:
api_statistics:
name: "이용 통계"
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:
+415
View File
@@ -19622,6 +19622,421 @@ input[type=checkbox]:checked + .custom-checkbox {
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;
}
.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 {
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
+1
View File
@@ -66,6 +66,7 @@
@use 'pages/terms-agreements' as *;
@use 'pages/service' as *;
@use 'pages/api-statistics' as *;
@use 'pages/webhook' as *;
// 6. Themes
@use 'themes/dark' as *;
@@ -0,0 +1,269 @@
// 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; }
}
}
}
// ---------- 비밀번호 모달 ----------
.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; }
}
}
}
@@ -0,0 +1,43 @@
<!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">
<section class="webhook-container">
<div class="webhook-empty">
<div class="empty-icon">🔔</div>
<h3>등록된 Webhook이 없습니다</h3>
<p>API 서비스의 점검·지연·장애 알림을 받을 Webhook을 신청해보세요.</p>
<div class="webhook-empty-action" sec:authorize="hasRole('ROLE_APP')">
<button type="button" class="btn-webhook-create" id="requestWebhook">
Webhook 신청
</button>
</div>
</div>
</section>
</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,158 @@
<!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">
<section class="webhook-container">
<div class="webhook-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>
<button type="button" class="btn-mini" id="btnRevealSecret">조회</button>
<button type="button" class="btn-mini" id="btnRegenSecret">재발급</button>
</span>
</div>
<div class="webhook-card-actions">
<a class="btn-webhook-next" th:href="@{/webhook/modify/step1}">수정</a>
<button type="button" class="btn-webhook-danger" id="btnDeleteWebhook">삭제</button>
</div>
</div>
</section>
<!-- 비밀번호 재인증 모달 -->
<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 조회
document.getElementById('btnRevealSecret').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 재발급
document.getElementById('btnRegenSecret').addEventListener('click', function () {
openModal('Secret 재발급', '재발급 시 기존 Secret은 무효화됩니다. 계속하려면 비밀번호를 입력하세요.', function (pw) {
post('/webhook/regenerate-secret', pw).then(function (res) {
if (res.success) {
document.getElementById('secretMasked').textContent = res.secret;
closeModal();
alert('Secret이 재발급되었습니다. 새 값을 안전하게 보관하세요.');
} else { showError(res.message || '실패했습니다.'); }
}).catch(function () { showError('요청 처리 중 오류가 발생했습니다.'); });
});
});
// 삭제
document.getElementById('btnDeleteWebhook').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,59 @@
<!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">
<section class="webhook-container webhook-register">
<div class="webhook-steps" th:with="cur=${currentStep}">
<div class="webhook-step" th:classappend="${cur >= 1} ? 'active'">
<span class="step-no">1</span><span class="step-label">기본 정보</span>
</div>
<div class="webhook-step" th:classappend="${cur >= 2} ? 'active'">
<span class="step-no">2</span><span class="step-label">API 선택</span>
</div>
<div class="webhook-step" th:classappend="${cur >= 3} ? 'active'">
<span class="step-no">3</span><span class="step-label">완료</span>
</div>
</div>
<form id="modifyStep1Form" method="post" th:action="@{/webhook/modify/step1}" th:object="${webhookModification}">
<div class="webhook-field">
<label for="targetUrl">Webhook 수신 URL <span class="req">*</span></label>
<input type="text" id="targetUrl" th:field="*{targetUrl}"
placeholder="https://example.com/webhook" autocomplete="off">
<p class="field-error" th:if="${#fields.hasErrors('targetUrl')}" th:errors="*{targetUrl}">URL 오류</p>
<p class="field-help">이벤트 발생 시 이 URL로 서명된 POST 요청이 전송됩니다.</p>
</div>
<div class="webhook-field">
<label>알림 받을 이벤트 <span class="req">*</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>
<div class="webhook-actions">
<a class="btn-webhook-cancel" th:href="@{/webhook/modify/cancel}">취소</a>
<button type="submit" class="btn-webhook-next">다음</button>
</div>
</form>
</section>
</th:block>
</body>
</html>
@@ -0,0 +1,55 @@
<!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">
<section class="webhook-container webhook-register">
<div class="webhook-steps" th:with="cur=${currentStep}">
<div class="webhook-step" th:classappend="${cur >= 1} ? 'active'">
<span class="step-no">1</span><span class="step-label">기본 정보</span>
</div>
<div class="webhook-step" th:classappend="${cur >= 2} ? 'active'">
<span class="step-no">2</span><span class="step-label">API 선택</span>
</div>
<div class="webhook-step" th:classappend="${cur >= 3} ? 'active'">
<span class="step-no">3</span><span class="step-label">완료</span>
</div>
</div>
<p class="webhook-field-error" th:if="${error}" th:text="${error}"></p>
<form id="modifyStep2Form" method="post" th:action="@{/webhook/modify/step2}">
<p class="field-help">이 Webhook으로 상태 알림을 받을 API를 선택하세요. (1개 이상)</p>
<div class="webhook-api-groups">
<div class="webhook-api-group" th:each="group : ${apiGroups}">
<h3 class="group-name" th:text="${group.groupName}">그룹명</h3>
<div class="webhook-api-list">
<label class="webhook-api-card" th:each="api : ${group.apiGroupApiList}">
<input type="checkbox" name="selectedApis" th:value="${api.apiId}"
th:checked="${selectedApis != null and #lists.contains(selectedApis, api.apiId)}">
<span class="api-name" th:text="${api.apiName}">API 이름</span>
<span class="api-id" th:text="${api.apiId}">API ID</span>
</label>
</div>
</div>
</div>
<div class="webhook-actions">
<a class="btn-webhook-cancel" th:href="@{/webhook/modify/step1}">이전</a>
<button type="submit" class="btn-webhook-next">수정 완료</button>
</div>
</form>
</section>
</th:block>
</body>
</html>
@@ -0,0 +1,34 @@
<!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">
<section class="webhook-container webhook-register">
<div class="webhook-steps">
<div class="webhook-step active"><span class="step-no">1</span><span class="step-label">기본 정보</span></div>
<div class="webhook-step active"><span class="step-no">2</span><span class="step-label">API 선택</span></div>
<div class="webhook-step active"><span class="step-no">3</span><span class="step-label">완료</span></div>
</div>
<div class="webhook-complete">
<div class="complete-icon"></div>
<h3>Webhook 설정이 수정되었습니다</h3>
<p class="field-help">Secret Key는 변경되지 않았습니다.</p>
<div class="webhook-actions center">
<a class="btn-webhook-next" th:href="@{/webhook}">완료</a>
</div>
</div>
</section>
</th:block>
</body>
</html>
@@ -0,0 +1,60 @@
<!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">
<section class="webhook-container webhook-register">
<!-- 진행 표시 -->
<div class="webhook-steps" th:with="cur=${currentStep}">
<div class="webhook-step" th:classappend="${cur >= 1} ? 'active'">
<span class="step-no">1</span><span class="step-label">기본 정보</span>
</div>
<div class="webhook-step" th:classappend="${cur >= 2} ? 'active'">
<span class="step-no">2</span><span class="step-label">API 선택</span>
</div>
<div class="webhook-step" th:classappend="${cur >= 3} ? 'active'">
<span class="step-no">3</span><span class="step-label">완료</span>
</div>
</div>
<form id="registerStep1Form" method="post" th:action="@{/webhook/register/step1}" th:object="${webhookRegistration}">
<div class="webhook-field">
<label for="targetUrl">Webhook 수신 URL <span class="req">*</span></label>
<input type="text" id="targetUrl" th:field="*{targetUrl}"
placeholder="https://example.com/webhook" autocomplete="off">
<p class="field-error" th:if="${#fields.hasErrors('targetUrl')}" th:errors="*{targetUrl}">URL 오류</p>
<p class="field-help">이벤트 발생 시 이 URL로 서명된 POST 요청이 전송됩니다.</p>
</div>
<div class="webhook-field">
<label>알림 받을 이벤트 <span class="req">*</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>
<div class="webhook-actions">
<a class="btn-webhook-cancel" th:href="@{/webhook/register/cancel}">취소</a>
<button type="submit" class="btn-webhook-next">다음</button>
</div>
</form>
</section>
</th:block>
</body>
</html>
@@ -0,0 +1,56 @@
<!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">
<section class="webhook-container webhook-register">
<div class="webhook-steps" th:with="cur=${currentStep}">
<div class="webhook-step" th:classappend="${cur >= 1} ? 'active'">
<span class="step-no">1</span><span class="step-label">기본 정보</span>
</div>
<div class="webhook-step" th:classappend="${cur >= 2} ? 'active'">
<span class="step-no">2</span><span class="step-label">API 선택</span>
</div>
<div class="webhook-step" th:classappend="${cur >= 3} ? 'active'">
<span class="step-no">3</span><span class="step-label">완료</span>
</div>
</div>
<p class="webhook-field-error" th:if="${error}" th:text="${error}"></p>
<form id="registerStep2Form" method="post" th:action="@{/webhook/register/step2}">
<p class="field-help">이 Webhook으로 상태 알림을 받을 API를 선택하세요. (1개 이상)</p>
<div class="webhook-api-groups">
<div class="webhook-api-group" th:each="group : ${apiGroups}">
<h3 class="group-name" th:text="${group.groupName}">그룹명</h3>
<div class="webhook-api-list">
<label class="webhook-api-card"
th:each="api : ${group.apiGroupApiList}">
<input type="checkbox" name="selectedApis" th:value="${api.apiId}"
th:checked="${selectedApis != null and #lists.contains(selectedApis, api.apiId)}">
<span class="api-name" th:text="${api.apiName}">API 이름</span>
<span class="api-id" th:text="${api.apiId}">API ID</span>
</label>
</div>
</div>
</div>
<div class="webhook-actions">
<a class="btn-webhook-cancel" th:href="@{/webhook/register/step1}">이전</a>
<button type="submit" class="btn-webhook-next">신청 완료</button>
</div>
</form>
</section>
</th:block>
</body>
</html>
@@ -0,0 +1,67 @@
<!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">
<section class="webhook-container webhook-register">
<div class="webhook-steps">
<div class="webhook-step active"><span class="step-no">1</span><span class="step-label">기본 정보</span></div>
<div class="webhook-step active"><span class="step-no">2</span><span class="step-label">API 선택</span></div>
<div class="webhook-step active"><span class="step-no">3</span><span class="step-label">완료</span></div>
</div>
<div class="webhook-complete">
<div class="complete-icon"></div>
<h3>Webhook이 등록되었습니다</h3>
<div class="webhook-secret-box">
<div class="secret-warning">
<i class="fas fa-exclamation-triangle"></i>
아래 Secret Key는 <strong>지금 한 번만</strong> 전체가 표시됩니다. 안전한 곳에 보관하세요.
(분실 시 재발급 필요)
</div>
<label>Secret Key</label>
<div class="secret-value-row">
<input type="text" id="secretValue" th:value="${secret}" readonly>
<button type="button" class="btn-copy" id="copySecret">복사</button>
</div>
</div>
<div class="webhook-actions center">
<a class="btn-webhook-next" th:href="@{/webhook}">완료</a>
</div>
</div>
</section>
</th:block>
<th:block layout:fragment="contentScript">
<script th:inline="javascript">
document.addEventListener('DOMContentLoaded', function () {
var btn = document.getElementById('copySecret');
var input = document.getElementById('secretValue');
if (btn && input) {
btn.addEventListener('click', function () {
input.select();
input.setSelectionRange(0, 99999);
navigator.clipboard.writeText(input.value).then(function () {
btn.textContent = '복사됨';
setTimeout(function () { btn.textContent = '복사'; }, 1500);
}).catch(function () {
document.execCommand('copy');
});
});
}
});
</script>
</th:block>
</body>
</html>
@@ -71,6 +71,7 @@
<li sec:authorize="hasRole('ROLE_APP')">
<a th:href="@{/myapikey}"><i class="fas fa-key"></i>인증 키 관리</a>
<a th:href="@{/statistics/api}"><i class="fas fa-chart-bar"></i>이용 통계</a>
<a th:href="@{/webhook}"><i class="fas fa-bell"></i>Webhook 관리</a>
</li>
<li><a th:href="@{/mypage}"><i class="fas fa-user-circle"></i>내 정보 관리</a></li>
<li><a th:href="@{/change_password}"><i class="fas fa-lock"></i>비밀번호 변경</a></li>
@@ -222,6 +223,7 @@
<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="@{/statistics/api}">이용 통계</a></li>
<li sec:authorize="hasRole('ROLE_APP')"><a th:href="@{/webhook}">Webhook 관리</a></li>
<li><a th:href="@{/mypage}">내 정보 관리</a></li>
<li><a th:href="@{/change_password}">비밀번호 변경</a></li>
</ul>