Files
eapim-portal/src/main/resources/templates/views/apps/mypage/credentialDetail.html
T
Rinjae 6c231c4189 API 해지 팝업 추가 및 사용자 오류 메시지 처리 개선
- API 해지 신청 팝업 UI 및 로직 구현
- 사용자 친화적 예외 처리 헬퍼(UserErrorMessageResolver) 추가
- Webhook 신청/수정, 글로벌 예외 처리에 사용자 메시지 연동
2026-07-30 16:22:45 +09:00

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>