6c231c4189
- API 해지 신청 팝업 UI 및 로직 구현 - 사용자 친화적 예외 처리 헬퍼(UserErrorMessageResolver) 추가 - Webhook 신청/수정, 글로벌 예외 처리에 사용자 메시지 연동
368 lines
17 KiB
HTML
368 lines
17 KiB
HTML
<!DOCTYPE html>
|
|
<html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
|
|
xmlns:sec="http://www.thymeleaf.org/extras/spring-security" layout:decorate="~{layout/djbank_title_layout}">
|
|
|
|
<head>
|
|
<title>API 키 상세</title>
|
|
</head>
|
|
|
|
<body>
|
|
<section layout:fragment="title">
|
|
<div class="page-title-banner">
|
|
<img th:src="@{/img/img_title_bg.png}" class="title-image">
|
|
<h1>앱관리</h1>
|
|
</div>
|
|
</section>
|
|
<th:block layout:fragment="contentFragment">
|
|
<div class="signup-guide-v2 figma-register-wrapper">
|
|
<div class="service-main app-management-layout">
|
|
|
|
<!-- Left Sidebar (Service Sidebar) -->
|
|
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('apiKey')}"></th:block>
|
|
|
|
<!-- Content Area -->
|
|
<div class="app-management-content">
|
|
<div class="detail-wrap">
|
|
<!-- Title Bar -->
|
|
<div class="board-header">
|
|
<h2 class="board-title">클라이언트 정보</h2>
|
|
<span class="board-desc">등록된 앱의 상세 정보를 확인할 수 있습니다.</span>
|
|
</div>
|
|
|
|
<!-- App Basic Info Card -->
|
|
<div class="dt-app-card">
|
|
<div class="dt-app-icon-box">
|
|
<img th:if="${apiKey.appIconFileId != null}"
|
|
th:src="@{/file/download(fileSn=1,fileId=${apiKey.appIconFileId})}" alt="앱 아이콘">
|
|
<svg th:unless="${apiKey.appIconFileId != null}" width="46" height="46" viewBox="0 0 24 24" fill="none"
|
|
stroke="#64748b" stroke-width="1.5">
|
|
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
|
|
<circle cx="8.5" cy="8.5" r="1.5"></circle>
|
|
<polyline points="21 15 16 10 5 21"></polyline>
|
|
</svg>
|
|
</div>
|
|
<div class="dt-app-info">
|
|
<div class="dt-app-status-row">
|
|
<span th:classappend="${apiKey.appstatus == '1' ? 'status-approved' : 'status-inactive'}"
|
|
class="dt-status-badge" th:text="${apiKey.appstatus == '1' ? '승인' : '비활성화'}">승인</span>
|
|
</div>
|
|
<h3 class="dt-app-name" th:text="${apiKey.clientname}">앱 이름</h3>
|
|
<p class="dt-app-desc"
|
|
th:text="${apiKey.appDescription != null ? apiKey.appDescription : '앱 설명이 없습니다.'}">앱 설명이 표시됩니다.</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Credential Info Section Card -->
|
|
<div class="dt-info-container">
|
|
|
|
<!-- Client ID -->
|
|
<div class="dt-row">
|
|
<div class="dt-col">
|
|
<label class="dt-label">Client ID</label>
|
|
<div class="dt-input-group">
|
|
<div class="dt-input-box" th:text="${apiKey.clientid}">K00000001</div>
|
|
<button type="button" class="dt-btn-copy" th:data-secret="${apiKey.clientid}"
|
|
onclick="copyToClipboardFromButton(this)">
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
|
stroke-width="2">
|
|
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
|
|
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
|
|
</svg>
|
|
<span>복사</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Client Secret -->
|
|
<div class="dt-row">
|
|
<div class="dt-col">
|
|
<label class="dt-label">
|
|
Client Secret
|
|
<button type="button" class="btn-guide-info" onclick="showLostKeyGuide()" title="비밀정보 안내"
|
|
aria-label="비밀정보 안내"
|
|
style="margin-left:6px;width:18px;height:18px;border-radius:50%;border:1px solid #bbb;background:#f5f5f5;color:#666;font-size:12px;line-height:1;cursor:pointer;padding:0;vertical-align:middle;">?</button>
|
|
</label>
|
|
|
|
<!-- secret이 아직 DB에 존재: 최초 1회 조회 가능 -->
|
|
<th:block th:if="${secretAvailable}">
|
|
<!-- View Button (shown initially) -->
|
|
<div class="dt-input-group" id="hiddenSecretBox">
|
|
<button type="button"
|
|
style="width: 100%; border-radius: 10px; background-color: #2a69de; color: #fff; height: 47px; display: flex; justify-content: center; align-items: center; border: none; font-size: 16px; font-weight: 500; cursor: pointer; transition: background-color 0.2s;"
|
|
onmouseover="this.style.backgroundColor='#1d56c0'"
|
|
onmouseout="this.style.backgroundColor='#2a69de'" onclick="showPasswordPrompt()">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none"
|
|
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
|
style="margin-right: 8px;">
|
|
<circle cx="11" cy="11" r="8"></circle>
|
|
<path d="m21 21-4.35-4.35"></path>
|
|
</svg>
|
|
<span>조회</span>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Actual Secret (hidden initially, 값은 서버 응답으로 JS가 주입) -->
|
|
<div id="revealedSecretBox" style="display: none; width: 100%;">
|
|
<div class="dt-input-group">
|
|
<div class="dt-input-box" id="revealedSecretValue" style="flex: 1;"></div>
|
|
<button type="button" class="dt-btn-copy" id="revealedSecretCopyBtn"
|
|
onclick="copyToClipboardFromButton(this)">
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
|
stroke-width="2">
|
|
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
|
|
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
|
|
</svg>
|
|
<span>복사</span>
|
|
</button>
|
|
</div>
|
|
<p class="secret-warning"
|
|
style="margin:10px 0 0;padding:10px 12px;background:#fdecea;border:1px solid #f5c6c2;border-radius:6px;color:#c0392b;font-size:13px;line-height:1.6;">
|
|
⚠ 이 값은 <strong>지금 한 번만</strong> 표시되며, 이후에는 다시 조회할 수 없습니다.<br>
|
|
반드시 <strong>안전한 곳에 복사하여 보관</strong>해 주세요.
|
|
</p>
|
|
</div>
|
|
</th:block>
|
|
|
|
<!-- secret이 이미 노출·삭제됨 -->
|
|
<div th:unless="${secretAvailable}" class="secret-revealed-notice"
|
|
style="display:flex;align-items:center;gap:12px;">
|
|
<span class="secret-revealed-text" style="color:#888;font-size:14px;">이미 1회 노출되어 삭제된 인증정보입니다.</span>
|
|
<button type="button" class="btn-guide-link" onclick="showLostKeyGuide()"
|
|
style="background:none;border:none;color:#2b6cb0;text-decoration:underline;cursor:pointer;font-size:13px;padding:0;">분실
|
|
시 안내</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Callback URL (기능본에서 비활성 처리됨 — 렌더링되지 않음) -->
|
|
<!--/*
|
|
<div class="dt-row">
|
|
<div class="dt-col">
|
|
<label class="dt-label">Callback URL</label>
|
|
<div class="dt-input-group">
|
|
<div class="dt-input-box" th:text="${apiKey.redirecturi != null ? apiKey.redirecturi : '설정되지 않음'}">http://www.abcd.co.kr</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
*/-->
|
|
|
|
<!-- 화이트 리스트 -->
|
|
<div class="dt-row">
|
|
<div class="dt-col">
|
|
<label class="dt-label">화이트 리스트</label>
|
|
<div class="dt-input-group">
|
|
<div class="dt-input-box" th:text="${apiKey.allowedips != null ? apiKey.allowedips : '설정되지 않음'}">
|
|
설정되지 않음</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- API 목록 -->
|
|
<div class="dt-row">
|
|
<div class="dt-col">
|
|
<label class="dt-label">API 목록</label>
|
|
<div class="dt-api-list-box">
|
|
<div class="dt-api-item" th:each="api : ${apiKey.apiList}">
|
|
<span class="dt-api-name" th:text="${api.apiDesc}">추가된 API 1</span>
|
|
<span class="dt-api-status-badge">승인</span>
|
|
</div>
|
|
<div class="dt-empty-message" th:if="${apiKey.apiList == null or apiKey.apiList.isEmpty()}">
|
|
등록된 API가 없습니다.
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 최종 수정일자 -->
|
|
<div class="dt-row">
|
|
<div class="dt-col">
|
|
<label class="dt-label">최종 수정일자</label>
|
|
<div class="dt-input-group">
|
|
<div class="dt-input-box" th:text="${#temporals.format(apiKey.modifiedon, 'yyyy.MM.dd')}">2025.11.22
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Bottom Navigation Actions -->
|
|
<div class="dt-actions" style="justify-content: flex-end; gap: 11px; margin-top: 30px;">
|
|
<a th:href="@{/clients}" class="dt-btn-gray">목록</a>
|
|
<button type="button" sec:authorize="hasRole('ROLE_API_KEY_REQUEST')" class="dt-btn-red"
|
|
th:data-client-id="${apiKey.clientid}" onclick="deleteApiKeyFromButton(this)"
|
|
th:disabled="${pendingDeleteRequest}"
|
|
th:text="${pendingDeleteRequest} ? '해지 승인 대기중' : 'API 이용 해지'">API 이용 해지</button>
|
|
<a sec:authorize="hasRole('ROLE_API_KEY_REQUEST')" th:unless="${pendingDeleteRequest}" class="dt-btn-blue"
|
|
th:href="@{/clients/modify/step1(clientId=${apiKey.clientid})}">변경 신청</a>
|
|
</div>
|
|
|
|
</div><!-- /detail-wrap -->
|
|
</div><!-- /app-management-content -->
|
|
</div><!-- /service-main -->
|
|
</div><!-- /signup-guide-v2 -->
|
|
|
|
</th:block>
|
|
|
|
<th:block layout:fragment="contentScript">
|
|
<script th:inline="javascript">
|
|
/*<![CDATA[*/
|
|
|
|
var CREDENTIAL_CLIENT_ID = /*[[${apiKey.clientid}]]*/ '';
|
|
|
|
// 비밀정보 안내(분실 시 조치) 가이드 팝업
|
|
function showLostKeyGuide() {
|
|
customPopups.showAlert(
|
|
'Client Secret은 보안을 위해 <strong>최초 1회만 조회</strong>할 수 있으며,<br>' +
|
|
'조회하는 즉시 개발자포탈에서 <strong>영구 삭제</strong>됩니다.<br><br>' +
|
|
'값을 분실하셨다면 복구가 불가능하므로,<br>' +
|
|
'<strong>인증키를 새로 신청</strong>하여 발급받아 주세요.'
|
|
);
|
|
}
|
|
|
|
// Client Secret 조회 진입 — 확인 후 조회 (본인 확인은 step-up 2FA)
|
|
function showPasswordPrompt() {
|
|
customPopups.showConfirm(
|
|
'Client Secret은 <strong>지금 한 번만</strong> 조회할 수 있으며,<br>조회 즉시 값은 <strong>영구 삭제</strong>됩니다.<br><br>조회하시겠습니까?',
|
|
function (confirmed) {
|
|
if (!confirmed) {
|
|
return;
|
|
}
|
|
doRevealSecret();
|
|
}
|
|
);
|
|
}
|
|
|
|
// Secret 조회 요청 (step-up 필요 시 2FA 후 동일 요청 재시도)
|
|
function doRevealSecret() {
|
|
$.ajax({
|
|
url: /*[[@{/clients/credential/reveal-secret}]]*/ '/clients/credential/reveal-secret',
|
|
type: 'POST',
|
|
contentType: 'application/json',
|
|
data: JSON.stringify({ clientId: CREDENTIAL_CLIENT_ID }),
|
|
headers: {
|
|
'X-XSRF-TOKEN': /*[[${_csrf.token}]]*/ 'token'
|
|
}
|
|
}).done(function (response) {
|
|
if (response.success) {
|
|
// 서버가 반환한 secret을 화면에 1회 주입
|
|
$('#revealedSecretValue').text(response.secret);
|
|
$('#revealedSecretCopyBtn').attr('data-secret', response.secret).data('secret', response.secret);
|
|
|
|
$('#hiddenSecretBox').hide();
|
|
$('#revealedSecretBox').fadeIn(300);
|
|
} else if (response.alreadyRevealed) {
|
|
showLostKeyGuide();
|
|
} else {
|
|
customPopups.showAlert(response.message || 'Client Secret 조회에 실패했습니다.');
|
|
}
|
|
}).fail(function (jqXHR) {
|
|
if (isStepUpRequired(jqXHR)) {
|
|
requireStepUp('/clients/credential/reveal-secret', function () { doRevealSecret(); });
|
|
return;
|
|
}
|
|
customPopups.showAlert('오류가 발생했습니다. 다시 시도해주세요.');
|
|
});
|
|
}
|
|
|
|
// Copy to clipboard function - called from button with data-secret attribute
|
|
function copyToClipboardFromButton(button) {
|
|
var text = $(button).data('secret');
|
|
|
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
navigator.clipboard.writeText(text).then(function () {
|
|
alert('클립보드에 복사되었습니다.');
|
|
}).catch(function (err) {
|
|
console.error('Failed to copy:', err);
|
|
fallbackCopy(text);
|
|
});
|
|
} else {
|
|
fallbackCopy(text);
|
|
}
|
|
}
|
|
|
|
function fallbackCopy(text) {
|
|
const textArea = document.createElement('textarea');
|
|
textArea.value = text;
|
|
textArea.style.position = 'fixed';
|
|
textArea.style.left = '-999999px';
|
|
document.body.appendChild(textArea);
|
|
textArea.select();
|
|
try {
|
|
document.execCommand('copy');
|
|
alert('클립보드에 복사되었습니다.');
|
|
} catch (err) {
|
|
alert('복사에 실패했습니다.');
|
|
}
|
|
document.body.removeChild(textArea);
|
|
}
|
|
|
|
// API 이용 해지 신청 진입 - 경고 + 사유 모달 (본인 확인은 step-up 2FA가 담당)
|
|
function deleteApiKeyFromButton(button) {
|
|
var clientId = $(button).data('client-id');
|
|
|
|
customPopups.showTerminateRequest({
|
|
onConfirm: function (reason) {
|
|
doDeleteApiKey(clientId, reason);
|
|
}
|
|
});
|
|
}
|
|
|
|
// 해지 신청 요청 (step-up 필요 시 2FA 후 동일 요청 재시도)
|
|
function doDeleteApiKey(clientId, reason) {
|
|
$('.loading-overlay').show();
|
|
|
|
$.ajax({
|
|
url: /*[[@{/clients/api_key_delete}]]*/ '/clients/api_key_delete',
|
|
type: 'POST',
|
|
contentType: 'application/json',
|
|
data: JSON.stringify({ clientId: clientId, reason: reason }),
|
|
headers: {
|
|
'X-XSRF-TOKEN': /*[[${_csrf.token}]]*/ 'token'
|
|
}
|
|
}).done(function (response) {
|
|
if (response && response.success === false) {
|
|
customPopups.showTerminateError(response.msg || '해지 신청에 실패했습니다.');
|
|
return;
|
|
}
|
|
customPopups.hideTerminateRequest();
|
|
customPopups.showAlert(response.msg || '해지 신청이 접수되었습니다. 관리자 승인 후 인증키가 삭제됩니다.', function () {
|
|
window.location.href = /*[[@{/clients}]]*/ '/clients';
|
|
});
|
|
}).fail(function (jqXHR, textStatus, errorThrown) {
|
|
if (isStepUpRequired(jqXHR)) {
|
|
requireStepUp('/clients/api_key_delete', function () { doDeleteApiKey(clientId, reason); });
|
|
return;
|
|
}
|
|
customPopups.showTerminateError('해지 신청 요청 중 오류가 발생했습니다: ' + errorThrown);
|
|
}).always(function () {
|
|
$('.loading-overlay').hide();
|
|
});
|
|
}
|
|
|
|
// step-up 2FA 응답(401 + stepUpRequired) 판별 및 처리 헬퍼
|
|
function isStepUpRequired(jqXHR) {
|
|
return jqXHR && jqXHR.status === 401 && jqXHR.responseJSON && jqXHR.responseJSON.stepUpRequired;
|
|
}
|
|
|
|
function requireStepUp(purpose, retry) {
|
|
if (typeof TwoFactorAuth === 'undefined') {
|
|
customPopups.showAlert('추가 인증이 필요합니다. 다시 시도해주세요.');
|
|
return;
|
|
}
|
|
TwoFactorAuth.open({
|
|
mode: 'stepup',
|
|
purpose: purpose,
|
|
onSuccess: function () { retry(); },
|
|
onCancel: function () { /* 취소: 원 작업 중단 */ }
|
|
});
|
|
}
|
|
|
|
/*]]>*/
|
|
</script>
|
|
|
|
</th:block>
|
|
|
|
</body>
|
|
|
|
</html> |