인증키 수정 화면 스타일 적용

This commit is contained in:
현성필
2025-12-01 14:20:26 +09:00
parent 45c8c8961f
commit 00c014c73d
4 changed files with 587 additions and 637 deletions
@@ -1,42 +1,71 @@
<!DOCTYPE html> <!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org" <html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
xmlns:sec="http://www.thymeleaf.org/extras/spring-security" xmlns:sec="http://www.thymeleaf.org/extras/spring-security"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/kjbank_base_layout}"> xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/kjbank_title_layout}">
<body> <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"> <th:block layout:fragment="contentFragment">
<section class="apikey-register-container"> <section class="apikey-register-container">
<!-- Title Bar -->
<div class="register-title-bar">
<h2 class="register-title">앱 수정</h2>
<span class="register-subtitle">서비스 중인 앱 정보를 수정합니다.</span>
</div>
<!-- Progress Indicator --> <!-- Progress Indicator -->
<div class="register-progress"> <div class="register-progress">
<div class="progress-steps"> <div class="progress-steps">
<div class="progress-step active"> <!-- Step 1: 앱 정보수정 (Active) -->
<div class="step-number">1</div> <div class="progress-step-item active">
<div class="step-label">기본 정보</div> <div class="step-icon-wrapper">
<div class="step-icon">
<img th:src="@{/img/apikey_step1.png}" alt="앱 정보수정" width="36" height="36">
</div>
</div>
<div class="step-label">앱 정보수정</div>
</div>
<!-- Dots -->
<div class="step-dots">
<span></span><span></span><span></span>
</div>
<!-- Step 2: API 선택 -->
<div class="progress-step-item">
<div class="step-icon-wrapper">
<div class="step-icon">
<img th:src="@{/img/apikey_step2.png}" alt="API 선택" width="36" height="36">
</div>
</div> </div>
<div class="progress-line"></div>
<div class="progress-step">
<div class="step-number">2</div>
<div class="step-label">API 선택</div> <div class="step-label">API 선택</div>
</div> </div>
<div class="progress-line"></div>
<div class="progress-step"> <!-- Dots -->
<div class="step-number">3</div> <div class="step-dots">
<span></span><span></span><span></span>
</div>
<!-- Step 3: 완료 -->
<div class="progress-step-item">
<div class="step-icon-wrapper">
<div class="step-icon">
<img th:src="@{/img/apikey_step3.png}" alt="완료" width="36" height="36">
</div>
</div>
<div class="step-label">완료</div> <div class="step-label">완료</div>
</div> </div>
</div> </div>
</div> </div>
<!-- Header -->
<div class="register-header">
<h1>API Key 수정</h1>
<h2>Step 1: 기본 정보 수정</h2>
<p class="header-description">
앱의 기본 정보를 수정해주세요. 변경 사항은 승인 후 적용됩니다.
</p>
<!-- Error Message --> <!-- Error Message -->
<div th:if="${error}" class="alert alert-error" style="margin-top: 20px;"> <div th:if="${error}" class="alert alert-error">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle> <circle cx="12" cy="12" r="10"></circle>
<line x1="12" y1="8" x2="12" y2="12"></line> <line x1="12" y1="8" x2="12" y2="12"></line>
@@ -44,46 +73,48 @@
</svg> </svg>
<span th:text="${error}"></span> <span th:text="${error}"></span>
</div> </div>
</div>
<!-- Form Content --> <!-- Form Content -->
<div class="register-content"> <div class="register-form-container">
<form id="modifyStep1Form" method="post" th:action="@{/myapikey/modify/step1}" th:object="${apiKeyModification}" class="register-form" enctype="multipart/form-data"> <form id="modifyStep1Form" method="post" th:action="@{/myapikey/modify/step1}" th:object="${apiKeyModification}" class="register-form" enctype="multipart/form-data">
<!-- Hidden field for clientId --> <!-- Hidden field for clientId -->
<input type="hidden" th:field="*{clientId}"/> <input type="hidden" th:field="*{clientId}"/>
<div class="form-card">
<!-- App Icon --> <!-- App Icon -->
<div class="form-group"> <div class="form-row">
<label for="appIcon" class="form-label required"> <div class="form-label-wrapper">
앱 아이콘 <span class="form-label-text">앱 아이콘</span>
<span class="required-mark">*</span> <span class="required-badge">필수</span>
</label> </div>
<div class="icon-upload-wrapper"> <div class="form-field-wrapper">
<div class="icon-preview" id="iconPreview"> <div class="icon-upload-box">
<div class="icon-placeholder" <div class="icon-preview-area" id="iconPreview">
th:if="${apiKeyModification.appIconData == null and apiKeyModification.appIconFileId == null}"> <div class="icon-placeholder" id="iconPlaceholder"
th:style="${(apiKeyModification.appIconData != null or apiKeyModification.appIconFileId != null) ? 'display: none;' : 'display: flex;'}">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path> <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="17 8 12 3 7 8"></polyline> <polyline points="17 8 12 3 7 8"></polyline>
<line x1="12" y1="3" x2="12" y2="15"></line> <line x1="12" y1="3" x2="12" y2="15"></line>
</svg> </svg>
<span>이미지 선택</span>
</div> </div>
<!-- Show image from file service if available --> <!-- Show image from file service if available -->
<img id="iconPreviewImage" <img id="iconPreviewImage"
th:if="${apiKeyModification.appIconFileId != null}" th:if="${apiKeyModification.appIconFileId != null}"
th:src="@{/file/download(fileSn=1,fileId=${apiKeyModification.appIconFileId})}" th:src="@{/file/download(fileSn=1,fileId=${apiKeyModification.appIconFileId})}"
alt="Icon Preview"> alt="Icon Preview"
<!-- Show image from data if available (newly uploaded) --> style="display: block;">
<!-- Show image from data if available -->
<img id="iconPreviewImage" <img id="iconPreviewImage"
th:if="${apiKeyModification.appIconData != null and apiKeyModification.appIconFileId == null}" th:if="${apiKeyModification.appIconData != null and apiKeyModification.appIconFileId == null}"
th:src="'data:' + ${apiKeyModification.appIconContentType} + ';base64,' + ${T(java.util.Base64).getEncoder().encodeToString(apiKeyModification.appIconData)}" th:src="'data:' + ${apiKeyModification.appIconContentType} + ';base64,' + ${T(java.util.Base64).getEncoder().encodeToString(apiKeyModification.appIconData)}"
alt="Icon Preview"> alt="Icon Preview"
style="display: block;">
<!-- JS Empty Image Target -->
<img id="iconPreviewImageJS" style="display: none;" alt="Icon Preview">
<button type="button" class="btn-remove-icon" id="btnRemoveIcon" <button type="button" class="btn-remove-icon" id="btnRemoveIcon"
th:style="${(apiKeyModification.appIconData != null or apiKeyModification.appIconFileId != null) ? 'display: block;' : 'display: none;'}" th:style="${(apiKeyModification.appIconData != null or apiKeyModification.appIconFileId != null) ? 'display: flex;' : 'display: none;'}"
onclick="removeAppIcon()" title="아이콘 삭제"> onclick="removeAppIcon()" title="아이콘 삭제">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"></line> <line x1="18" y1="6" x2="6" y2="18"></line>
@@ -91,82 +122,97 @@
</svg> </svg>
</button> </button>
</div> </div>
<div class="icon-upload-info">
<p class="upload-title">앱 아이콘 이미지 파일을 업로드하세요</p>
<p class="upload-hint">권장사이즈는 <strong>128X128픽셀</strong>이며 <strong>JPG, PNG, GIF</strong>파일만 등록할 수 있습니다.</p>
</div>
<button type="button" class="btn-upload" onclick="document.getElementById('appIcon').click()">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="17 8 12 3 7 8"></polyline>
<line x1="12" y1="3" x2="12" y2="15"></line>
</svg>
이미지 파일 업로드
</button>
<input type="file" <input type="file"
id="appIcon" id="appIcon"
name="appIcon" name="appIcon"
class="icon-input" class="icon-input"
accept="image/png,image/jpeg,image/jpg"> accept="image/png,image/jpeg,image/jpg,image/gif"
<button type="button" class="btn-icon-select" onclick="document.getElementById('appIcon').click()"> style="display: none;">
파일 선택 </div>
</button>
</div> </div>
<small class="form-hint">PNG, JPG 형식 (최대 2MB, 권장 크기: 512x512px)</small>
</div> </div>
<!-- App Name --> <!-- App Name -->
<div class="form-group"> <div class="form-row">
<label for="appName" class="form-label required"> <div class="form-label-wrapper label-offset">
앱 이름 <span class="form-label-text">앱 이름</span>
<span class="required-mark">*</span> <span class="required-badge">필수</span>
</label> </div>
<div class="form-field-wrapper">
<input type="text" <input type="text"
id="appName" id="appName"
th:field="*{appName}" th:field="*{appName}"
class="form-control" class="form-input"
placeholder="예: My Application" placeholder="앱 이름을 입력하세요."
maxlength="50" required
required> maxlength="100">
<small class="form-hint">앱을 구별할 수 있는 고유한 이름을 입력해주세요. (최대 50자)</small> </div>
</div> </div>
<!-- App Description --> <!-- App Description -->
<div class="form-group"> <div class="form-row">
<label for="appDescription" class="form-label">앱 설명</label> <div class="form-label-wrapper">
<span class="form-label-text">앱 설명</span>
<span class="required-badge">필수</span>
</div>
<div class="form-field-wrapper">
<textarea id="appDescription" <textarea id="appDescription"
th:field="*{appDescription}" th:field="*{appDescription}"
class="form-control" class="form-textarea"
rows="4" placeholder="앱 설명을 입력하세요."
placeholder="앱의 주요 기능과 용도를 간단히 설명해주세요." rows="5"
required
maxlength="500"></textarea> maxlength="500"></textarea>
<small class="form-hint">앱의 용도와 주요 기능을 설명해주세요. (최대 500자)</small> <small class="char-counter-wrapper">
<span class="char-counter">0 / 500</span>
</small>
</div>
</div> </div>
<!-- Callback URL --> <!-- Callback URL -->
<div class="form-group"> <div class="form-row">
<label for="callbackUrl" class="form-label">Callback URL</label> <div class="form-label-wrapper label-offset">
<input type="url" <span class="form-label-text">Call Back URL</span>
</div>
<div class="form-field-wrapper">
<input type="text"
id="callbackUrl" id="callbackUrl"
th:field="*{callbackUrl}" th:field="*{callbackUrl}"
class="form-control" class="form-input"
placeholder="https://example.com/callback"> placeholder="URL을 입력해 주세요.">
<small class="form-hint">OAuth 2.0 인증 시 사용할 콜백 URL을 입력해주세요.</small> </div>
</div> </div>
<!-- IP Whitelist --> <!-- IP Whitelist -->
<div class="form-group"> <div class="form-row">
<label for="ipWhitelist" class="form-label"> <div class="form-label-wrapper label-offset">
IP 화이트리스트 <span class="form-label-text">화이트리스트</span>
</label> </div>
<div class="ip-input-group"> <div class="form-field-wrapper">
<div class="ip-input-row">
<input type="text" <input type="text"
id="ipWhitelistInput" id="ipWhitelistInput"
class="form-input" class="form-input ip-input"
placeholder="예: 192.168.1.1 또는 10.0.0.0/24"> placeholder="서버 IP (111.111.111.111)">
<button type="button" class="btn-add-ip" onclick="addIpAddress()"> <button type="button" class="btn-add-ip" onclick="addIpAddress()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> + 추가
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
추가
</button> </button>
</div> </div>
<small class="form-hint">허용할 IP 주소 또는 CIDR 대역을 입력하세요. (선택사항)</small>
<!-- IP List Display --> <!-- IP List Display -->
<div class="ip-list" id="ipList" style="display: none;"> <div class="ip-list" id="ipList" style="display: none;">
<div class="ip-list-header">
<span class="ip-count">등록된 IP: <strong id="ipCount">0</strong></span>
</div>
<div class="ip-items" id="ipItems"> <div class="ip-items" id="ipItems">
<!-- Dynamically added IP items --> <!-- Dynamically added IP items -->
</div> </div>
@@ -175,47 +221,46 @@
<!-- Hidden input to store IP list --> <!-- Hidden input to store IP list -->
<input type="hidden" id="ipWhitelist" name="ipWhitelist" value=""> <input type="hidden" id="ipWhitelist" name="ipWhitelist" value="">
</div> </div>
</div>
<!-- Action Buttons -->
<div class="form-actions">
<a th:href="@{/myapikey/modify/cancel(clientId=${apiKeyModification.clientId})}" class="btn btn-cancel">
취소
</a>
<button type="submit" class="btn btn-primary">
다음 단계
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="9 18 15 12 9 6"></polyline>
</svg>
</button>
</div> </div>
</form> </form>
</div> </div>
<!-- Form Actions -->
<div class="form-actions">
<a th:href="@{/myapikey/modify/cancel(clientId=${apiKeyModification.clientId})}" class="btn btn-submit btn-secondary">
취소
</a>
<button type="submit" form="modifyStep1Form" class="btn-primary btn-submit">
다음
</button>
</div>
</section> </section>
</th:block> </th:block>
<th:block layout:fragment="contentScript"> <th:block layout:fragment="contentScript">
<script th:inline="javascript"> <script th:inline="javascript">
/*<![CDATA[*/
// IP address list management // IP address list management
let ipAddresses = []; let ipAddresses = [];
// Remove app icon // Remove app icon
function removeAppIcon() { function removeAppIcon() {
const appIconInput = document.getElementById('appIcon'); const appIconInput = document.getElementById('appIcon');
const iconPreviewImage = document.getElementById('iconPreviewImage'); const iconPreviewImages = document.querySelectorAll('img[id^="iconPreviewImage"]'); // Select all preview images
const iconPlaceholder = document.querySelector('.icon-placeholder'); const iconPlaceholder = document.getElementById('iconPlaceholder');
const btnRemoveIcon = document.getElementById('btnRemoveIcon'); const btnRemoveIcon = document.getElementById('btnRemoveIcon');
// Clear file input // Clear file input
appIconInput.value = ''; appIconInput.value = '';
// Hide preview image and show placeholder // Hide all preview images
iconPreviewImage.style.display = 'none'; iconPreviewImages.forEach(img => {
iconPreviewImage.src = ''; img.style.display = 'none';
if (img.id === 'iconPreviewImageJS') img.src = '';
});
// Show placeholder
iconPlaceholder.style.display = 'flex'; iconPlaceholder.style.display = 'flex';
// Hide delete button // Hide delete button
@@ -229,20 +274,20 @@
// Validate IP format // Validate IP format
if (!ipValue) { if (!ipValue) {
alert('IP 주소를 입력해주세요.'); customPopups.showAlert('IP 주소를 입력해주세요.');
return; return;
} }
// Basic IP validation (supports IP and CIDR notation) // Basic IP validation (supports IP and CIDR notation)
const ipPattern = /^(\d{1,3}\.){3}\d{1,3}(\/\d{1,2})?$/; const ipPattern = /^(\d{1,3}\.){3}\d{1,3}(\/\d{1,2})?$/;
if (!ipPattern.test(ipValue)) { if (!ipPattern.test(ipValue)) {
alert('올바른 IP 주소 형식이 아닙니다.\n예: 192.168.1.1 또는 10.0.0.0/24'); customPopups.showAlert('올바른 IP 주소 형식이 아닙니다.\n예: 192.168.1.1 또는 10.0.0.0/24');
return; return;
} }
// Check for duplicates // Check for duplicates
if (ipAddresses.includes(ipValue)) { if (ipAddresses.includes(ipValue)) {
alert('이미 추가된 IP 주소입니다.'); customPopups.showAlert('이미 추가된 IP 주소입니다.');
return; return;
} }
@@ -270,12 +315,8 @@
function updateIpList() { function updateIpList() {
const ipList = document.getElementById('ipList'); const ipList = document.getElementById('ipList');
const ipItems = document.getElementById('ipItems'); const ipItems = document.getElementById('ipItems');
const ipCount = document.getElementById('ipCount');
const ipWhitelistHidden = document.getElementById('ipWhitelist'); const ipWhitelistHidden = document.getElementById('ipWhitelist');
// Update count
ipCount.textContent = ipAddresses.length;
// Show/hide list container // Show/hide list container
if (ipAddresses.length > 0) { if (ipAddresses.length > 0) {
ipList.style.display = 'block'; ipList.style.display = 'block';
@@ -285,12 +326,9 @@
ipAddresses.forEach(function(ip) { ipAddresses.forEach(function(ip) {
itemsHTML += ` itemsHTML += `
<div class="ip-item"> <div class="ip-item">
<span class="ip-address">${ip}</span> <div class="ip-display">${ip}</div>
<button type="button" class="btn-remove-ip" onclick="removeIpAddress('${ip}')" title="제거"> <button type="button" class="btn-remove-ip" onclick="removeIpAddress('${ip}')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> - 제거
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button> </button>
</div> </div>
`; `;
@@ -306,19 +344,17 @@
ipWhitelistHidden.value = ipAddresses.join(','); ipWhitelistHidden.value = ipAddresses.join(',');
} }
$(document).ready(function() { document.addEventListener('DOMContentLoaded', function() {
// Initialize with existing icon data const form = document.getElementById('modifyStep1Form');
var hasExistingIcon = /*[[${apiKeyModification.appIconData != null}]]*/ false; const appDescTextarea = document.getElementById('appDescription');
var hasExistingIconFile = /*[[${apiKeyModification.appIconFileId != null}]]*/ false; const charCounter = document.querySelector('.char-counter');
const appIconInput = document.getElementById('appIcon');
// If we have existing icon data, it's already shown by Thymeleaf const iconPreviewImageJS = document.getElementById('iconPreviewImageJS');
// But we need to ensure the UI state is correct const iconPlaceholder = document.getElementById('iconPlaceholder');
if (hasExistingIcon || hasExistingIconFile) { const ipWhitelistInput = document.getElementById('ipWhitelistInput');
$('.icon-placeholder').hide();
$('#btnRemoveIcon').show();
}
// Restore existing IP whitelist // Restore existing IP whitelist
/*<![CDATA[*/
const sessionIpWhitelist = /*[[${apiKeyModification.ipWhitelistAsString}]]*/ ''; const sessionIpWhitelist = /*[[${apiKeyModification.ipWhitelistAsString}]]*/ '';
if (sessionIpWhitelist && sessionIpWhitelist.trim() !== '') { if (sessionIpWhitelist && sessionIpWhitelist.trim() !== '') {
const ips = sessionIpWhitelist.split(','); const ips = sessionIpWhitelist.split(',');
@@ -330,46 +366,110 @@
}); });
updateIpList(); updateIpList();
} }
/*]]>*/
// File upload preview // Initialize character counter
$('#appIcon').on('change', function(e) { if (appDescTextarea && charCounter) {
var file = e.target.files[0]; const currentLength = appDescTextarea.value.length;
const maxLength = appDescTextarea.getAttribute('maxlength');
charCounter.textContent = currentLength + ' / ' + maxLength;
appDescTextarea.addEventListener('input', function() {
const currentLength = this.value.length;
const maxLength = this.getAttribute('maxlength');
charCounter.textContent = currentLength + ' / ' + maxLength;
if (currentLength > maxLength * 0.9) {
charCounter.style.color = '#FF6B6B';
} else {
charCounter.style.color = '#94A3B8';
}
});
}
// App icon preview
if (appIconInput) {
appIconInput.addEventListener('change', function(e) {
const file = e.target.files[0];
const btnRemoveIcon = document.getElementById('btnRemoveIcon'); const btnRemoveIcon = document.getElementById('btnRemoveIcon');
const iconPreviewImage = document.getElementById('iconPreviewImage');
const iconPlaceholder = document.querySelector('.icon-placeholder');
if (file) { if (file) {
if (file.size > 2 * 1024 * 1024) { if (file.size > 2 * 1024 * 1024) {
alert('파일 크기는 2MB를 초과할 수 없습니다.'); customPopups.showAlert('파일 크기는 2MB를 초과할 수 없습니다.');
$(this).val(''); this.value = '';
return; return;
} }
var reader = new FileReader(); const allowedTypes = ['image/png', 'image/jpeg', 'image/jpg', 'image/gif'];
reader.onload = function(e) { if (!allowedTypes.includes(file.type)) {
iconPreviewImage.src = e.target.result; customPopups.showAlert('PNG, JPG, GIF 형식만 업로드 가능합니다.');
iconPreviewImage.style.display = 'block'; this.value = '';
return;
}
const reader = new FileReader();
reader.onload = function(event) {
// Hide other previews
document.querySelectorAll('img[id^="iconPreviewImage"]').forEach(img => img.style.display = 'none');
iconPreviewImageJS.src = event.target.result;
iconPreviewImageJS.style.display = 'block';
iconPlaceholder.style.display = 'none'; iconPlaceholder.style.display = 'none';
btnRemoveIcon.style.display = 'flex'; btnRemoveIcon.style.display = 'flex';
}; };
reader.readAsDataURL(file); reader.readAsDataURL(file);
} }
}); });
}
// IP whitelist input - Enter key handler // IP input Enter key
$('#ipWhitelistInput').on('keypress', function(e) { if (ipWhitelistInput) {
ipWhitelistInput.addEventListener('keypress', function(e) {
if (e.key === 'Enter') { if (e.key === 'Enter') {
e.preventDefault(); e.preventDefault();
addIpAddress(); addIpAddress();
} }
}); });
}
// Form submission - ensure IP list is updated // Form validation
$('#modifyStep1Form').on('submit', function(e) { form.addEventListener('submit', function(e) {
// IP whitelist is already updated via updateIpList() const appName = document.getElementById('appName').value.trim();
const appDescription = document.getElementById('appDescription').value.trim();
const callbackUrl = document.getElementById('callbackUrl').value.trim();
if (document.getElementById('iconPlaceholder').style.display !== 'none') {
e.preventDefault();
customPopups.showAlert('앱 아이콘을 등록해주세요.');
return false;
}
if (!appName) {
e.preventDefault();
customPopups.showAlert('앱 이름을 입력해주세요.');
document.getElementById('appName').focus();
return false;
}
if (!appDescription) {
e.preventDefault();
customPopups.showAlert('앱 설명을 입력해주세요.');
document.getElementById('appDescription').focus();
return false;
}
if (callbackUrl && callbackUrl.trim() !== '') {
try {
new URL(callbackUrl);
} catch (error) {
e.preventDefault();
customPopups.showAlert('올바른 URL 형식이 아닙니다.');
document.getElementById('callbackUrl').focus();
return false;
}
}
}); });
}); });
/*]]>*/
</script> </script>
</th:block> </th:block>
@@ -1,41 +1,71 @@
<!DOCTYPE html> <!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org" <html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
xmlns:sec="http://www.thymeleaf.org/extras/spring-security" xmlns:sec="http://www.thymeleaf.org/extras/spring-security"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/kjbank_base_layout}"> xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/kjbank_title_layout}">
<body> <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"> <th:block layout:fragment="contentFragment">
<section class="apikey-register-container">
<!-- Title Bar -->
<div class="register-title-bar">
<h2 class="register-title">앱 수정</h2>
<span class="register-subtitle">API를 이용하여 서비스할 앱 정보를 수정해주세요.</span>
</div>
<!-- Progress Indicator --> <!-- Progress Indicator -->
<div class="register-progress"> <div class="register-progress">
<div class="progress-steps"> <div class="progress-steps">
<div class="progress-step completed"> <!-- Step 1: 앱 정보입력 (Completed) -->
<div class="step-number"></div> <div class="progress-step-item">
<div class="step-label">기본 정보</div> <div class="step-icon-wrapper">
<div class="step-icon">
<img th:src="@{/img/apikey_step1.png}" alt="앱 정보입력" width="36" height="36">
</div>
</div>
<div class="step-label">앱 정보입력</div>
</div>
<!-- Dots between Step 1 and 2 -->
<div class="step-dots">
<span></span><span></span><span></span>
</div>
<!-- Step 2: API 선택 (Active) -->
<div class="progress-step-item active">
<div class="step-icon-wrapper">
<div class="step-icon">
<img th:src="@{/img/apikey_step2.png}" alt="API 선택" width="36" height="36">
</div>
</div> </div>
<div class="progress-line filled"></div>
<div class="progress-step active">
<div class="step-number">2</div>
<div class="step-label">API 선택</div> <div class="step-label">API 선택</div>
</div> </div>
<div class="progress-line"></div>
<div class="progress-step"> <!-- Dots between Step 2 and 3 -->
<div class="step-number">3</div> <div class="step-dots">
<div class="step-label">완료</div> <span></span><span></span><span></span>
</div>
</div>
</div> </div>
<!-- Header --> <!-- Step 3: 앱 수정 완료 -->
<div class="register-header"> <div class="progress-step-item">
<h1>API Key 수정</h1> <div class="step-icon-wrapper">
<h2>Step 2: API 선택</h2> <div class="step-icon">
<p class="header-description"> <img th:src="@{/img/apikey_step3.png}" alt="앱 수정 완료" width="36" height="36">
사용할 API를 선택해주세요. 여러 개의 API를 선택할 수 있습니다. </div>
</p> </div>
<div class="step-label">앱 수정 완료</div>
</div>
</div>
</div>
<!-- Error Message --> <!-- Error Message -->
<div th:if="${error}" class="alert alert-error" style="margin-top: 20px;"> <div th:if="${error}" class="alert alert-error">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle> <circle cx="12" cy="12" r="10"></circle>
<line x1="12" y1="8" x2="12" y2="12"></line> <line x1="12" y1="8" x2="12" y2="12"></line>
@@ -43,19 +73,21 @@
</svg> </svg>
<span th:text="${error}"></span> <span th:text="${error}"></span>
</div> </div>
</div>
<!-- Main Container with Sidebar Layout --> <!-- Form Content with Sidebar Layout -->
<section class="apikey-register-container with-sidebar"> <div class="register-form-container with-sidebar">
<!-- Sidebar Navigation --> <!-- Sidebar Navigation -->
<aside class="apikey-register-sidebar" id="apiSidebar"> <aside class="apikey-register-sidebar" id="apiSidebar">
<nav class="apikey-sidebar-nav"> <nav class="apikey-sidebar-nav">
<!-- Sidebar Title -->
<div class="sidebar-title">API 서비스 목록</div>
<!-- All APIs --> <!-- All APIs -->
<div class="menu-section"> <div class="menu-section">
<div class="menu-title active" data-group=""> <div class="menu-title active" data-group="">
전체 <span class="menu-text">전체</span>
<span class="api-count" id="totalApiCount">0</span> <span class="api-count"></span>
</div> </div>
</div> </div>
@@ -63,8 +95,8 @@
<div class="menu-section" th:each="service : ${apiServices}"> <div class="menu-section" th:each="service : ${apiServices}">
<div class="menu-title" <div class="menu-title"
th:data-group="${service.id}"> th:data-group="${service.id}">
[[${service.groupName}]] <span class="menu-text" th:text="${service.groupName}">서비스명</span>
<span class="api-count" th:text="${service.apiGroupApiList.size()}">0</span> <span class="api-count"></span>
</div> </div>
</div> </div>
</nav> </nav>
@@ -89,7 +121,8 @@
<div class="api-filter-header"> <div class="api-filter-header">
<div class="select-all-wrapper" id="selectAllWrapper" style="display: none;"> <div class="select-all-wrapper" id="selectAllWrapper" style="display: none;">
<label class="select-all-label"> <label class="select-all-label">
<input type="checkbox" id="selectAllCheckbox" class="select-all-checkbox"> <input type="checkbox" id="selectAllCheckbox" class="select-all-checkbox visually-hidden">
<span class="custom-checkbox"></span>
<span id="selectAllText">전체 선택</span> <span id="selectAllText">전체 선택</span>
</label> </label>
</div> </div>
@@ -98,15 +131,12 @@
id="apiSearch" id="apiSearch"
class="search-input" class="search-input"
placeholder="API 검색..."> placeholder="API 검색...">
<span class="search-icon">🔍</span> <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>
</div> </div>
<!-- Result Count -->
<div class="api-result-count">
<strong id="visibleCount">0</strong>
</div>
<!-- API Cards Grid --> <!-- API Cards Grid -->
<div class="api-selection-card-grid" id="apiCardGrid"> <div class="api-selection-card-grid" id="apiCardGrid">
<!-- Loading state --> <!-- Loading state -->
@@ -126,37 +156,33 @@
</div> </div>
<!-- Form Actions -->
<div class="form-actions">
<!-- Save and go back button -->
<button type="submit" formaction="/myapikey/modify/step2/save" class="btn btn-secondary" id="btnPrevStep">
<span class="icon"></span>
이전 단계
</button>
<button type="submit" class="btn btn-primary" id="btnNext">
저장
<span class="icon"></span>
</button>
</div>
</form> </form>
</main> </main>
</section> </div>
<!-- Floating Cart Button --> <!-- Form Actions -->
<div class="form-actions">
<button type="button" id="btnPrevStep" class="btn-submit btn-secondary">
이전
</button>
<button type="submit" form="modifyStep2Form" class="btn-submit btn-primary">
저장
</button>
</div>
</section>
<!-- Floating Cart Button (Figma Design) -->
<button type="button" class="floating-cart-btn" id="floatingCartBtn" style="display: none;"> <button type="button" class="floating-cart-btn" id="floatingCartBtn" style="display: none;">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <span class="cart-label">선택된 API</span>
<circle cx="9" cy="21" r="1"></circle> <span class="cart-badge" id="cartBadge">
<circle cx="20" cy="21" r="1"></circle> <span class="cart-count">0</span>
<path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6"></path> <span class="cart-unit"></span>
</svg> </span>
<span class="cart-text">선택된 API:</span>
<span class="cart-badge" id="cartBadge">0</span>
</button> </button>
<!-- Selected APIs Modal --> <!-- Selected APIs Modal - Uses base modal component -->
<div class="selected-apis-modal" id="selectedApisModal" style="display: none;"> <div class="modal" id="selectedApisModal" style="display: none;">
<div class="modal-backdrop" id="modalOverlay"></div> <div class="modal-backdrop" id="modalOverlay"></div>
<div class="modal-dialog"> <div class="modal-dialog">
<div class="modal-header"> <div class="modal-header">
@@ -190,11 +216,9 @@
const mobileToggle = document.getElementById('mobileToggle'); const mobileToggle = document.getElementById('mobileToggle');
const mobileOverlay = document.getElementById('mobileOverlay'); const mobileOverlay = document.getElementById('mobileOverlay');
const menuTitles = document.querySelectorAll('.menu-title'); const menuTitles = document.querySelectorAll('.menu-title');
const visibleCount = document.getElementById('visibleCount');
const apiCardGrid = document.getElementById('apiCardGrid'); const apiCardGrid = document.getElementById('apiCardGrid');
const loadingState = document.getElementById('loadingState'); const loadingState = document.getElementById('loadingState');
const emptyState = document.getElementById('emptyState'); const emptyState = document.getElementById('emptyState');
const totalApiCount = document.getElementById('totalApiCount');
let currentFilter = ''; // Empty string means "all" let currentFilter = ''; // Empty string means "all"
let currentServiceName = '전체'; // Store current service name let currentServiceName = '전체'; // Store current service name
@@ -238,30 +262,18 @@
url += '?groupIds=' + encodeURIComponent(groupId); url += '?groupIds=' + encodeURIComponent(groupId);
} }
fetch(url) fetch(url).then(response => response.json()).then(apis => {
.then(response => response.json())
.then(apis => {
allApis = apis; allApis = apis;
loadingState.style.display = 'none'; loadingState.style.display = 'none';
if (apis.length === 0) { if (apis.length === 0) {
emptyState.style.display = 'block'; emptyState.style.display = 'block';
visibleCount.textContent = '0';
return; return;
} }
renderApiCards(apis); renderApiCards(apis);
visibleCount.textContent = apis.length;
// Update total count
if (!groupId || groupId === '') {
totalApiCount.textContent = apis.length;
}
// Update select all UI
updateSelectAllUI(); updateSelectAllUI();
}) }).catch(error => {
.catch(error => {
console.error('Failed to load APIs:', error); console.error('Failed to load APIs:', error);
loadingState.style.display = 'none'; loadingState.style.display = 'none';
emptyState.querySelector('h3').textContent = 'API 로드 실패'; emptyState.querySelector('h3').textContent = 'API 로드 실패';
@@ -270,50 +282,6 @@
}); });
} }
// Update select all UI visibility and label
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 (checked/indeterminate/unchecked)
function updateSelectAllCheckboxState() {
const selectAllCheckbox = document.getElementById('selectAllCheckbox');
const visibleCards = Array.from(document.querySelectorAll('.api-selection-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('.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;
}
}
// Render API cards // Render API cards
function renderApiCards(apis) { function renderApiCards(apis) {
const fragment = document.createDocumentFragment(); const fragment = document.createDocumentFragment();
@@ -336,8 +304,7 @@
function createApiCard(api) { function createApiCard(api) {
const card = document.createElement('div'); const card = document.createElement('div');
card.className = 'api-selection-card'; card.className = 'api-selection-card';
// Note: AJAX response doesn't have apiGroupId, use apiGroupName or service for grouping card.setAttribute('data-group', api.apiGroupId || '');
card.setAttribute('data-group', api.apiGroupName || '');
card.setAttribute('data-name', (api.apiName || '').toLowerCase()); card.setAttribute('data-name', (api.apiName || '').toLowerCase());
card.setAttribute('data-desc', (api.apiSimpleDescription || '').toLowerCase()); card.setAttribute('data-desc', (api.apiSimpleDescription || '').toLowerCase());
card.setAttribute('data-api-id', api.apiId); card.setAttribute('data-api-id', api.apiId);
@@ -347,29 +314,32 @@
card.classList.add('selected'); 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 = ` card.innerHTML = `
<div class="api-card-content">
<div class="api-card-header">
<span class="api-card-category">${api.service || '카테고리'}</span>
<div class="checkbox-wrapper"> <div class="checkbox-wrapper">
<input type="checkbox" <input type="checkbox"
name="selectedApis" name="selectedApis"
value="${api.apiId}" value="${api.apiId}"
id="api-${api.apiId}" id="api-${api.apiId}"
class="api-checkbox" class="api-checkbox visually-hidden"
${isSelected ? 'checked' : ''}> ${isSelected ? 'checked' : ''}>
<span class="custom-checkbox"></span>
</div> </div>
<label class="api-card-content" for="api-${api.apiId}">
<div class="api-card-header">
<span class="api-card-badge">${api.service || '카테고리'}</span>
<span class="api-method">${api.apiMethod || 'GET'}</span>
</div> </div>
<h3 class="api-name">${api.apiName || 'API 이름'}</h3> <h3 class="api-name">${api.apiName || 'API 이름'}</h3>
<p class="api-description">${api.apiSimpleDescription || 'API 설명이 없습니다.'}</p> <p class="api-description">${api.apiSimpleDescription || 'API 설명이 없습니다.'}</p>
<div class="api-endpoint"> <div class="api-card-icon">
<code>${api.apiUrl || '/api/v1/example'}</code> ${mainIconHtml}
</div>
</div> </div>
</label>
`; `;
return card; return card;
@@ -384,13 +354,13 @@
checkbox.addEventListener('change', function() { checkbox.addEventListener('change', function() {
updateCardSelection(this); updateCardSelection(this);
updateSelectedCount(); updateSelectedCount();
updateSelectAllCheckboxState();
}); });
}); });
apiCards.forEach(card => { apiCards.forEach(card => {
card.addEventListener('click', function(e) { card.addEventListener('click', function(e) {
if (e.target.classList.contains('api-checkbox') || e.target.tagName === 'LABEL') { // Prevent double toggle if clicking directly on checkbox
if (e.target.classList.contains('api-checkbox')) {
return; return;
} }
const checkbox = card.querySelector('.api-checkbox'); const checkbox = card.querySelector('.api-checkbox');
@@ -398,7 +368,6 @@
checkbox.checked = !checkbox.checked; checkbox.checked = !checkbox.checked;
updateCardSelection(checkbox); updateCardSelection(checkbox);
updateSelectedCount(); updateSelectedCount();
updateSelectAllCheckboxState();
} }
}); });
}); });
@@ -420,17 +389,63 @@
function updateSelectedCount() { function updateSelectedCount() {
// Update floating cart button // Update floating cart button
const floatingCartBtn = document.getElementById('floatingCartBtn'); const floatingCartBtn = document.getElementById('floatingCartBtn');
const cartBadge = document.getElementById('cartBadge'); const cartCount = document.querySelector('.cart-count');
if (selectedApis.size > 0) { if (selectedApis.size > 0) {
floatingCartBtn.style.display = 'flex'; floatingCartBtn.style.display = 'flex';
cartBadge.textContent = selectedApis.size; cartCount.textContent = selectedApis.size;
} else { } else {
floatingCartBtn.style.display = 'none'; floatingCartBtn.style.display = 'none';
} }
// Update modal list // Update modal list
updateModalList(); 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('.api-selection-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('.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 // Update modal selected APIs list
@@ -444,8 +459,6 @@
} }
// Build list using selectedApis Set for consistency // Build list using selectedApis Set for consistency
// This ensures we show all selected APIs regardless of current filter/view
console.log('updateModalList: Building modal for', selectedApis.size, 'selected APIs');
selectedApis.forEach(function(apiId) { selectedApis.forEach(function(apiId) {
// Try to find the checkbox in the DOM first // Try to find the checkbox in the DOM first
const checkbox = document.querySelector('.api-checkbox[value="' + apiId + '"]'); const checkbox = document.querySelector('.api-checkbox[value="' + apiId + '"]');
@@ -483,12 +496,11 @@
</svg> </svg>
</button> </button>
`; `;
console.log('Adding pill for:', apiId, '→', apiName);
modalSelectedList.appendChild(apiPill); modalSelectedList.appendChild(apiPill);
}); });
// Add remove handlers to newly created elements // Add remove handlers
modalSelectedList.querySelectorAll('.api-pill-remove').forEach(function(btn) { document.querySelectorAll('.api-pill-remove').forEach(function(btn) {
btn.addEventListener('click', function() { btn.addEventListener('click', function() {
const value = this.getAttribute('data-value'); const value = this.getAttribute('data-value');
const checkbox = document.querySelector('.api-checkbox[value="' + value + '"]'); const checkbox = document.querySelector('.api-checkbox[value="' + value + '"]');
@@ -510,7 +522,6 @@
if (searchInput) { if (searchInput) {
searchInput.addEventListener('input', function() { searchInput.addEventListener('input', function() {
const searchTerm = this.value.toLowerCase(); const searchTerm = this.value.toLowerCase();
let visibleCountNum = 0;
const apiCards = document.querySelectorAll('.api-selection-card'); const apiCards = document.querySelectorAll('.api-selection-card');
apiCards.forEach(function(card) { apiCards.forEach(function(card) {
@@ -522,13 +533,12 @@
if (matchesSearch) { if (matchesSearch) {
card.style.display = ''; card.style.display = '';
visibleCountNum++;
} else { } else {
card.style.display = 'none'; card.style.display = 'none';
} }
}); });
visibleCount.textContent = visibleCountNum; // Update select all checkbox state after search
updateSelectAllCheckboxState(); updateSelectAllCheckboxState();
}); });
} }
@@ -546,7 +556,7 @@
const groupId = this.getAttribute('data-group'); const groupId = this.getAttribute('data-group');
currentFilter = groupId; currentFilter = groupId;
// Store current service name (extract text before the count) // Store service name for select all label
currentServiceName = this.textContent.trim().split('\n')[0].trim(); currentServiceName = this.textContent.trim().split('\n')[0].trim();
// Load APIs for selected service // Load APIs for selected service
@@ -665,13 +675,12 @@
} }
}); });
// Select all checkbox event handler // Select All checkbox event
const selectAllCheckbox = document.getElementById('selectAllCheckbox'); const selectAllCheckbox = document.getElementById('selectAllCheckbox');
if (selectAllCheckbox) { if (selectAllCheckbox) {
selectAllCheckbox.addEventListener('change', function() { selectAllCheckbox.addEventListener('change', function() {
const isChecked = this.checked; const isChecked = this.checked;
const visibleCards = Array.from(document.querySelectorAll('.api-selection-card')) const visibleCards = Array.from(document.querySelectorAll('.api-selection-card')).filter(card => card.style.display !== 'none');
.filter(card => card.style.display !== 'none');
visibleCards.forEach(function(card) { visibleCards.forEach(function(card) {
const checkbox = card.querySelector('.api-checkbox'); const checkbox = card.querySelector('.api-checkbox');
@@ -685,6 +694,21 @@
}); });
} }
// 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
const originalAction = form.action;
form.action = '/myapikey/modify/step2/save';
// Submit the form to save selections
form.submit();
});
}
// Form validation // Form validation
form.addEventListener('submit', function(e) { form.addEventListener('submit', function(e) {
// Allow submission even without selected APIs (user might want to remove all) // Allow submission even without selected APIs (user might want to remove all)
@@ -701,11 +725,10 @@
// Load all APIs for modal display FIRST (async) // Load all APIs for modal display FIRST (async)
loadAllApisForModal(); loadAllApisForModal();
// Load all APIs automatically on page load updateSelectedCount(); // This will show count from restored session data
loadApis('');
// Update count after APIs are loaded (this will show count from restored session data) // Load all APIs automatically on page load (empty string = all APIs)
updateSelectedCount(); loadApis('');
// Log restored selections for debugging // Log restored selections for debugging
if (selectedApis.size > 0) { if (selectedApis.size > 0) {
@@ -1,86 +1,116 @@
<!DOCTYPE html> <!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org" <html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
xmlns:sec="http://www.thymeleaf.org/extras/spring-security" xmlns:sec="http://www.thymeleaf.org/extras/spring-security"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/kjbank_base_layout}"> xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/kjbank_title_layout}">
<body> <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"> <th:block layout:fragment="contentFragment">
<section class="apikey-register-container"> <section class="apikey-register-container">
<!-- Progress Indicator --> <!-- Title Bar -->
<div class="register-title-bar">
<h2 class="register-title">앱 수정</h2>
<span class="register-subtitle">API를 이용하여 서비스할 앱 정보를 수정해주세요.</span>
</div>
<div class="register-progress"> <div class="register-progress">
<div class="progress-steps"> <div class="progress-steps">
<div class="progress-step completed"> <!-- Step 1: 앱 정보입력 -->
<div class="step-number"></div> <div class="progress-step-item">
<div class="step-label">기본 정보</div> <div class="step-icon-wrapper">
<div class="step-icon">
<img th:src="@{/img/apikey_step1.png}" alt="앱 정보입력" width="36" height="36">
</div>
</div>
<div class="step-label">앱 정보입력</div>
</div>
<!-- Dots between Step 1 and 2 -->
<div class="step-dots">
<span></span><span></span><span></span>
</div>
<!-- Step 2: API 선택 -->
<div class="progress-step-item">
<div class="step-icon-wrapper">
<div class="step-icon">
<img th:src="@{/img/apikey_step2.png}" alt="API 선택" width="36" height="36">
</div>
</div> </div>
<div class="progress-line filled"></div>
<div class="progress-step completed">
<div class="step-number"></div>
<div class="step-label">API 선택</div> <div class="step-label">API 선택</div>
</div> </div>
<div class="progress-line filled"></div>
<div class="progress-step active completed"> <!-- Dots between Step 2 and 3 -->
<div class="step-number"></div> <div class="step-dots">
<div class="step-label">완료</div> <span></span><span></span><span></span>
</div>
<!-- Step 3: 앱 수정 완료 (Active) -->
<div class="progress-step-item active">
<div class="step-icon-wrapper">
<div class="step-icon">
<img th:src="@{/img/apikey_step3.png}" alt="앱 수정 완료" width="36" height="36">
</div>
</div>
<div class="step-label">앱 수정 완료</div>
</div> </div>
</div> </div>
</div> </div>
<!-- Success Result --> <!-- Success Result -->
<div class="register-result success" th:if="${modificationComplete}"> <div class="register-result success" th:if="${modificationComplete}">
<!-- Success Icon Animation --> <!-- Success Icon Animation -->
<div class="result-icon-wrapper"> <div class="result-icon-wrapper">
<div class="result-icon success-icon"> <div class="result-icon success-icon">
<svg class="checkmark" viewBox="0 0 52 52"> <img th:src="@{/img/img_app_complete.png}">
<circle class="checkmark-circle" cx="26" cy="26" r="25" fill="none"/>
<path class="checkmark-check" fill="none" d="M14.1 27.2l7.1 7.2 16.7-16.8"/>
</svg>
</div> </div>
</div> </div>
<!-- Success Message --> <!-- Success Message -->
<div class="result-header"> <div class="result-header">
<h1>API Key 수정 요청 완료!</h1> <h1> 수정 요청 완료되었습니다.</h1>
<p class="result-description"> <p class="result-description">
API Key 수정 요청이 성공적으로 접수되었습니다. 관리자 승인 후 변경 사항이 적용됩니다. 담당자 승인 후 변경 사항이 적용됩니다. [앱 정보]화면에서 수정 내역을 확인할 수 있습니다.
</p> </p>
</div> </div>
<!-- Result Content --> <!-- API Key Display -->
<div class="result-content"> <div class="result-content">
<!-- Action Buttons --> <!-- Action Buttons -->
<div class="result-actions"> <div class="result-actions">
<a th:href="@{/myapikey}" class="btn btn-secondary"> <a href="/myapikey" class="btn btn-primary">
<span class="icon">📋</span> 인증키 관리
API Key 목록으로
</a>
<a th:href="@{/myapikey/api_key_request/history}" class="btn btn-primary">
<span class="icon">📄</span>
요청 내역 확인
</a> </a>
</div> </div>
</div> </div>
</div> </div>
<!-- Error Result (if needed) --> <!-- Error Result (if needed) -->
<div class="register-result error" th:unless="${modificationComplete}"> <div class="register-result error" th:unless="${modificationComplete}" style="display: none;">
<div class="result-icon-wrapper"> <div class="result-icon-wrapper">
<div class="result-icon error-icon"> <div class="result-icon error-icon">
<span></span> <span></span>
</div> </div>
</div> </div>
<div class="result-header"> <div class="result-header">
<h1>잘못된 접근입니다</h1> <h1>앱 수정 실패</h1>
<p class="result-description"> <p class="result-description">
수정 프로세스를 완료하지 않았거나 잘못된 경로로 접근하셨습니다. 수정 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.
</p> </p>
</div> </div>
<div class="result-actions"> <div class="result-actions">
<a th:href="@{/myapikey}" class="btn btn-primary"> <a href="/myapikey" class="btn btn-secondary">
목록으로 돌아가기 다시 시도
</a>
<a href="/myapikey" class="btn btn-primary">
목록으로
</a> </a>
</div> </div>
</div> </div>
@@ -89,64 +119,6 @@
</th:block> </th:block>
<th:block layout:fragment="contentScript"> <th:block layout:fragment="contentScript">
<script th:inline="javascript">
document.addEventListener('DOMContentLoaded', function() {
// Success animation
setTimeout(function() {
const successIcon = document.querySelector('.success-icon');
if (successIcon) {
successIcon.classList.add('animate');
}
}, 100);
});
</script>
<style>
/* Success checkmark animation */
.checkmark {
width: 52px;
height: 52px;
border-radius: 50%;
display: block;
stroke-width: 2;
stroke: #6BCF7F;
stroke-miterlimit: 10;
animation: rotate 1s ease-in-out;
}
.checkmark-circle {
stroke-dasharray: 166;
stroke-dashoffset: 166;
stroke-width: 2;
stroke-miterlimit: 10;
stroke: #6BCF7F;
animation: stroke 0.6s cubic-bezier(0.65, 0, 0.45, 1) forwards;
}
.checkmark-check {
transform-origin: 50% 50%;
stroke-dasharray: 48;
stroke-dashoffset: 48;
animation: stroke 0.3s cubic-bezier(0.65, 0, 0.45, 1) 0.6s forwards;
}
@keyframes stroke {
100% {
stroke-dashoffset: 0;
}
}
@keyframes rotate {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
</style>
</th:block> </th:block>
</body> </body>
</html> </html>
@@ -122,86 +122,6 @@
<script th:inline="javascript"> <script th:inline="javascript">
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
// Load data from sessionStorage
const appName = sessionStorage.getItem('registration_appName');
const selectedApis = JSON.parse(sessionStorage.getItem('registration_selectedApis') || '[]');
// Update display with actual data
if (appName) {
document.getElementById('resultAppName').textContent = appName;
}
// Copy to clipboard functionality
document.querySelectorAll('.btn-copy').forEach(function(btn) {
btn.addEventListener('click', function() {
const targetId = this.getAttribute('data-copy');
const targetElement = document.getElementById(targetId);
const textToCopy = targetElement.textContent;
// Create temporary input
const tempInput = document.createElement('input');
tempInput.value = textToCopy;
document.body.appendChild(tempInput);
tempInput.select();
try {
document.execCommand('copy');
// Visual feedback
const originalText = this.innerHTML;
this.innerHTML = '<span class="copy-icon">✓</span> 복사됨';
this.classList.add('copied');
setTimeout(() => {
this.innerHTML = originalText;
this.classList.remove('copied');
}, 2000);
} catch (err) {
console.error('Failed to copy:', err);
alert('복사에 실패했습니다. 수동으로 복사해주세요.');
}
document.body.removeChild(tempInput);
});
});
// Toggle visibility for secret key
document.querySelectorAll('.btn-toggle-visibility').forEach(function(btn) {
btn.addEventListener('click', function() {
const targetId = this.getAttribute('data-target');
const targetElement = document.getElementById(targetId);
if (targetElement.classList.contains('blurred')) {
targetElement.classList.remove('blurred');
this.innerHTML = '<span class="visibility-icon">🙈</span>';
} else {
targetElement.classList.add('blurred');
this.innerHTML = '<span class="visibility-icon">👁️</span>';
}
});
});
// Download key information
document.getElementById('downloadKeyBtn')?.addEventListener('click', function() {
const keyInfo = {
appName: document.getElementById('resultAppName').textContent,
clientId: document.getElementById('clientId').textContent,
clientSecret: document.getElementById('clientSecret').textContent,
createdAt: new Date().toISOString(),
selectedApis: selectedApis
};
const dataStr = JSON.stringify(keyInfo, null, 2);
const dataUri = 'data:application/json;charset=utf-8,' + encodeURIComponent(dataStr);
const exportFileDefaultName = 'apikey_' + keyInfo.appName.replace(/\s+/g, '_') + '.json';
const linkElement = document.createElement('a');
linkElement.setAttribute('href', dataUri);
linkElement.setAttribute('download', exportFileDefaultName);
linkElement.click();
});
// Clear session storage after successful registration // Clear session storage after successful registration
if (/*[[${registrationComplete}]]*/ false) { if (/*[[${registrationComplete}]]*/ false) {
sessionStorage.removeItem('registration_appName'); sessionStorage.removeItem('registration_appName');
@@ -210,73 +130,8 @@
sessionStorage.removeItem('registration_environment'); sessionStorage.removeItem('registration_environment');
sessionStorage.removeItem('registration_selectedApis'); sessionStorage.removeItem('registration_selectedApis');
} }
// Success animation
setTimeout(function() {
const successIcon = document.querySelector('.success-icon');
if (successIcon) {
successIcon.classList.add('animate');
}
}, 100);
}); });
</script> </script>
<style>
/* Blur effect for secret key */
.key-value.blurred {
filter: blur(5px);
user-select: none;
}
/* Copy button feedback */
.btn-copy.copied {
background-color: #6BCF7F !important;
color: white !important;
}
/* Success checkmark animation */
.checkmark {
width: 52px;
height: 52px;
border-radius: 50%;
display: block;
stroke-width: 2;
stroke: #6BCF7F;
stroke-miterlimit: 10;
animation: rotate 1s ease-in-out;
}
.checkmark-circle {
stroke-dasharray: 166;
stroke-dashoffset: 166;
stroke-width: 2;
stroke-miterlimit: 10;
stroke: #6BCF7F;
animation: stroke 0.6s cubic-bezier(0.65, 0, 0.45, 1) forwards;
}
.checkmark-check {
transform-origin: 50% 50%;
stroke-dasharray: 48;
stroke-dashoffset: 48;
animation: stroke 0.3s cubic-bezier(0.65, 0, 0.45, 1) 0.6s forwards;
}
@keyframes stroke {
100% {
stroke-dashoffset: 0;
}
}
@keyframes rotate {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
</style>
</th:block> </th:block>
</body> </body>
</html> </html>