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

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,89 +1,120 @@
<!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/kjbank_base_layout}">
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/kjbank_title_layout}">
<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">
<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 -->
<div class="register-progress">
<div class="progress-steps">
<div class="progress-step active">
<div class="step-number">1</div>
<div class="step-label">기본 정보</div>
<!-- Step 1: 앱 정보수정 (Active) -->
<div class="progress-step-item active">
<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>
<div class="progress-line"></div>
<div class="progress-step">
<div class="step-number">2</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 class="step-label">API 선택</div>
</div>
<div class="progress-line"></div>
<div class="progress-step">
<div class="step-number">3</div>
<!-- Dots -->
<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>
</div>
</div>
<!-- Header -->
<div class="register-header">
<h1>API Key 수정</h1>
<h2>Step 1: 기본 정보 수정</h2>
<p class="header-description">
앱의 기본 정보를 수정해주세요. 변경 사항은 승인 후 적용됩니다.
</p>
<!-- Error Message -->
<div th:if="${error}" class="alert alert-error" style="margin-top: 20px;">
<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>
<line x1="12" y1="8" x2="12" y2="12"></line>
<line x1="12" y1="16" x2="12.01" y2="16"></line>
</svg>
<span th:text="${error}"></span>
</div>
<!-- Error Message -->
<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">
<circle cx="12" cy="12" r="10"></circle>
<line x1="12" y1="8" x2="12" y2="12"></line>
<line x1="12" y1="16" x2="12.01" y2="16"></line>
</svg>
<span th:text="${error}"></span>
</div>
<!-- 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">
<!-- Hidden field for clientId -->
<input type="hidden" th:field="*{clientId}"/>
<div class="form-card">
<!-- App Icon -->
<div class="form-group">
<label for="appIcon" class="form-label required">
앱 아이콘
<span class="required-mark">*</span>
</label>
<div class="icon-upload-wrapper">
<div class="icon-preview" id="iconPreview">
<div class="icon-placeholder"
th:if="${apiKeyModification.appIconData == null and apiKeyModification.appIconFileId == null}">
<!-- App Icon -->
<div class="form-row">
<div class="form-label-wrapper">
<span class="form-label-text">앱 아이콘</span>
<span class="required-badge">필수</span>
</div>
<div class="form-field-wrapper">
<div class="icon-upload-box">
<div class="icon-preview-area" id="iconPreview">
<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">
<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>
<span>이미지 선택</span>
</div>
<!-- Show image from file service if available -->
<img id="iconPreviewImage"
th:if="${apiKeyModification.appIconFileId != null}"
th:src="@{/file/download(fileSn=1,fileId=${apiKeyModification.appIconFileId})}"
alt="Icon Preview">
<!-- Show image from data if available (newly uploaded) -->
alt="Icon Preview"
style="display: block;">
<!-- Show image from data if available -->
<img id="iconPreviewImage"
th:if="${apiKeyModification.appIconData != null and apiKeyModification.appIconFileId == null}"
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"
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="아이콘 삭제">
<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>
@@ -91,82 +122,97 @@
</svg>
</button>
</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"
id="appIcon"
name="appIcon"
class="icon-input"
accept="image/png,image/jpeg,image/jpg">
<button type="button" class="btn-icon-select" onclick="document.getElementById('appIcon').click()">
파일 선택
</button>
accept="image/png,image/jpeg,image/jpg,image/gif"
style="display: none;">
</div>
<small class="form-hint">PNG, JPG 형식 (최대 2MB, 권장 크기: 512x512px)</small>
</div>
</div>
<!-- App Name -->
<div class="form-group">
<label for="appName" class="form-label required">
앱 이름
<span class="required-mark">*</span>
</label>
<!-- App Name -->
<div class="form-row">
<div class="form-label-wrapper label-offset">
<span class="form-label-text">앱 이름</span>
<span class="required-badge">필수</span>
</div>
<div class="form-field-wrapper">
<input type="text"
id="appName"
th:field="*{appName}"
class="form-control"
placeholder="예: My Application"
maxlength="50"
required>
<small class="form-hint">앱을 구별할 수 있는 고유한 이름을 입력해주세요. (최대 50자)</small>
class="form-input"
placeholder="앱 이름을 입력하세요."
required
maxlength="100">
</div>
</div>
<!-- App Description -->
<div class="form-group">
<label for="appDescription" class="form-label">앱 설명</label>
<!-- App Description -->
<div class="form-row">
<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"
th:field="*{appDescription}"
class="form-control"
rows="4"
placeholder="앱의 주요 기능과 용도를 간단히 설명해주세요."
class="form-textarea"
placeholder="앱 설명을 입력하세요."
rows="5"
required
maxlength="500"></textarea>
<small class="form-hint">앱의 용도와 주요 기능을 설명해주세요. (최대 500자)</small>
<small class="char-counter-wrapper">
<span class="char-counter">0 / 500</span>
</small>
</div>
</div>
<!-- Callback URL -->
<div class="form-group">
<label for="callbackUrl" class="form-label">Callback URL</label>
<input type="url"
<!-- Callback URL -->
<div class="form-row">
<div class="form-label-wrapper label-offset">
<span class="form-label-text">Call Back URL</span>
</div>
<div class="form-field-wrapper">
<input type="text"
id="callbackUrl"
th:field="*{callbackUrl}"
class="form-control"
placeholder="https://example.com/callback">
<small class="form-hint">OAuth 2.0 인증 시 사용할 콜백 URL을 입력해주세요.</small>
class="form-input"
placeholder="URL을 입력해 주세요.">
</div>
</div>
<!-- IP Whitelist -->
<div class="form-group">
<label for="ipWhitelist" class="form-label">
IP 화이트리스트
</label>
<div class="ip-input-group">
<!-- IP Whitelist -->
<div class="form-row">
<div class="form-label-wrapper label-offset">
<span class="form-label-text">화이트리스트</span>
</div>
<div class="form-field-wrapper">
<div class="ip-input-row">
<input type="text"
id="ipWhitelistInput"
class="form-input"
placeholder="예: 192.168.1.1 또는 10.0.0.0/24">
class="form-input ip-input"
placeholder="서버 IP (111.111.111.111)">
<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>
</div>
<small class="form-hint">허용할 IP 주소 또는 CIDR 대역을 입력하세요. (선택사항)</small>
<!-- IP List Display -->
<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">
<!-- Dynamically added IP items -->
</div>
@@ -175,47 +221,46 @@
<!-- Hidden input to store IP list -->
<input type="hidden" id="ipWhitelist" name="ipWhitelist" value="">
</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>
</form>
</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>
</th:block>
<th:block layout:fragment="contentScript">
<script th:inline="javascript">
/*<![CDATA[*/
// IP address list management
let ipAddresses = [];
// Remove app icon
function removeAppIcon() {
const appIconInput = document.getElementById('appIcon');
const iconPreviewImage = document.getElementById('iconPreviewImage');
const iconPlaceholder = document.querySelector('.icon-placeholder');
const iconPreviewImages = document.querySelectorAll('img[id^="iconPreviewImage"]'); // Select all preview images
const iconPlaceholder = document.getElementById('iconPlaceholder');
const btnRemoveIcon = document.getElementById('btnRemoveIcon');
// Clear file input
appIconInput.value = '';
// Hide preview image and show placeholder
iconPreviewImage.style.display = 'none';
iconPreviewImage.src = '';
// Hide all preview images
iconPreviewImages.forEach(img => {
img.style.display = 'none';
if (img.id === 'iconPreviewImageJS') img.src = '';
});
// Show placeholder
iconPlaceholder.style.display = 'flex';
// Hide delete button
@@ -229,20 +274,20 @@
// Validate IP format
if (!ipValue) {
alert('IP 주소를 입력해주세요.');
customPopups.showAlert('IP 주소를 입력해주세요.');
return;
}
// Basic IP validation (supports IP and CIDR notation)
const ipPattern = /^(\d{1,3}\.){3}\d{1,3}(\/\d{1,2})?$/;
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;
}
// Check for duplicates
if (ipAddresses.includes(ipValue)) {
alert('이미 추가된 IP 주소입니다.');
customPopups.showAlert('이미 추가된 IP 주소입니다.');
return;
}
@@ -270,12 +315,8 @@
function updateIpList() {
const ipList = document.getElementById('ipList');
const ipItems = document.getElementById('ipItems');
const ipCount = document.getElementById('ipCount');
const ipWhitelistHidden = document.getElementById('ipWhitelist');
// Update count
ipCount.textContent = ipAddresses.length;
// Show/hide list container
if (ipAddresses.length > 0) {
ipList.style.display = 'block';
@@ -285,12 +326,9 @@
ipAddresses.forEach(function(ip) {
itemsHTML += `
<div class="ip-item">
<span class="ip-address">${ip}</span>
<button type="button" class="btn-remove-ip" onclick="removeIpAddress('${ip}')" title="제거">
<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>
<div class="ip-display">${ip}</div>
<button type="button" class="btn-remove-ip" onclick="removeIpAddress('${ip}')">
- 제거
</button>
</div>
`;
@@ -306,19 +344,17 @@
ipWhitelistHidden.value = ipAddresses.join(',');
}
$(document).ready(function() {
// Initialize with existing icon data
var hasExistingIcon = /*[[${apiKeyModification.appIconData != null}]]*/ false;
var hasExistingIconFile = /*[[${apiKeyModification.appIconFileId != null}]]*/ false;
// If we have existing icon data, it's already shown by Thymeleaf
// But we need to ensure the UI state is correct
if (hasExistingIcon || hasExistingIconFile) {
$('.icon-placeholder').hide();
$('#btnRemoveIcon').show();
}
document.addEventListener('DOMContentLoaded', function() {
const form = document.getElementById('modifyStep1Form');
const appDescTextarea = document.getElementById('appDescription');
const charCounter = document.querySelector('.char-counter');
const appIconInput = document.getElementById('appIcon');
const iconPreviewImageJS = document.getElementById('iconPreviewImageJS');
const iconPlaceholder = document.getElementById('iconPlaceholder');
const ipWhitelistInput = document.getElementById('ipWhitelistInput');
// Restore existing IP whitelist
/*<![CDATA[*/
const sessionIpWhitelist = /*[[${apiKeyModification.ipWhitelistAsString}]]*/ '';
if (sessionIpWhitelist && sessionIpWhitelist.trim() !== '') {
const ips = sessionIpWhitelist.split(',');
@@ -330,46 +366,110 @@
});
updateIpList();
}
/*]]>*/
// File upload preview
$('#appIcon').on('change', function(e) {
var file = e.target.files[0];
const btnRemoveIcon = document.getElementById('btnRemoveIcon');
const iconPreviewImage = document.getElementById('iconPreviewImage');
const iconPlaceholder = document.querySelector('.icon-placeholder');
// Initialize character counter
if (appDescTextarea && charCounter) {
const currentLength = appDescTextarea.value.length;
const maxLength = appDescTextarea.getAttribute('maxlength');
charCounter.textContent = currentLength + ' / ' + maxLength;
if (file) {
if (file.size > 2 * 1024 * 1024) {
alert('파일 크기는 2MB를 초과할 수 없습니다.');
$(this).val('');
return;
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';
}
});
}
var reader = new FileReader();
reader.onload = function(e) {
iconPreviewImage.src = e.target.result;
iconPreviewImage.style.display = 'block';
iconPlaceholder.style.display = 'none';
btnRemoveIcon.style.display = 'flex';
};
reader.readAsDataURL(file);
// App icon preview
if (appIconInput) {
appIconInput.addEventListener('change', function(e) {
const file = e.target.files[0];
const btnRemoveIcon = document.getElementById('btnRemoveIcon');
if (file) {
if (file.size > 2 * 1024 * 1024) {
customPopups.showAlert('파일 크기는 2MB를 초과할 수 없습니다.');
this.value = '';
return;
}
const allowedTypes = ['image/png', 'image/jpeg', 'image/jpg', 'image/gif'];
if (!allowedTypes.includes(file.type)) {
customPopups.showAlert('PNG, JPG, GIF 형식만 업로드 가능합니다.');
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';
btnRemoveIcon.style.display = 'flex';
};
reader.readAsDataURL(file);
}
});
}
// IP input Enter key
if (ipWhitelistInput) {
ipWhitelistInput.addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
e.preventDefault();
addIpAddress();
}
});
}
// Form validation
form.addEventListener('submit', function(e) {
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;
}
});
// IP whitelist input - Enter key handler
$('#ipWhitelistInput').on('keypress', function(e) {
if (e.key === 'Enter') {
if (!appName) {
e.preventDefault();
addIpAddress();
customPopups.showAlert('앱 이름을 입력해주세요.');
document.getElementById('appName').focus();
return false;
}
});
// Form submission - ensure IP list is updated
$('#modifyStep1Form').on('submit', function(e) {
// IP whitelist is already updated via updateIpList()
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>
</th:block>