Files
eapim-portal/src/main/resources/templates/views/apps/mypage/apiKeyRegisterStep1.html
T
Rinjae 02eb81af67
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
클라이언트 생성/수정 흐름 및 관련 UI 개선:
- 클라이언트 등록/수정 URL 구조 리팩토링 (myapikey → clients)
- 로그인 직후 클라이언트 미보유 사용자 대상 추가 안내 팝업
- 클라이언트 Secret 조회 및 관련 2FA 단계 로직 개선
2026-07-29 19:25:10 +09:00

425 lines
19 KiB
HTML

<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
xmlns:sec="http://www.thymeleaf.org/extras/spring-security" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout/djbank_title_layout}">
<body>
<section layout:fragment="title">
<div class="page-title-banner">
<img th:src="@{/img/img_title_bg.png}" class="title-image">
<h1>앱관리</h1>
</div>
</section>
<th:block layout:fragment="contentFragment">
<div class="signup-guide-v2 figma-register-wrapper">
<div class="service-main container" style="padding-top: 70px;">
<!-- Sidebar -->
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('apiKey')}"></th:block>
<!-- Content Area -->
<div class="app-management-content">
<!-- 모든 스타일은 step1-wrap 안에서만 적용 -->
<div class="step1-wrap">
<!-- Title -->
<h2 class="s1-title">클라이언트 정보 - 신규</h2>
<!-- Progress Steps Card -->
<div class="s1-progress-card">
<div class="s1-steps">
<!-- Step 1 Active: 앱 정보 입력 (document icon) -->
<div class="s1-step s1-step--active">
<div class="s1-step-circle">
<svg class="s1-step-svg" width="26" height="26" viewBox="0 0 24 24" fill="none"
xmlns="http://www.w3.org/2000/svg">
<rect x="3" y="5" width="18" height="14" rx="2" stroke="currentColor" stroke-width="1.8" />
<line x1="7" y1="9" x2="17" y2="9" stroke="currentColor" stroke-width="1.8"
stroke-linecap="round" />
<line x1="7" y1="12" x2="14" y2="12" stroke="currentColor" stroke-width="1.8"
stroke-linecap="round" />
<line x1="7" y1="15" x2="11" y2="15" stroke="currentColor" stroke-width="1.8"
stroke-linecap="round" />
</svg>
</div>
<span class="s1-step-num">1단계</span>
<span class="s1-step-name">앱 정보 입력</span>
</div>
<div class="s1-step-line"></div>
<!-- Step 2: API 선택 (chain link icon) -->
<div class="s1-step">
<div class="s1-step-circle">
<svg class="s1-step-svg" width="26" height="26" viewBox="0 0 24 24" fill="none"
xmlns="http://www.w3.org/2000/svg">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" stroke="currentColor"
stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" stroke="currentColor"
stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</div>
<span class="s1-step-num">2단계</span>
<span class="s1-step-name">API 선택</span>
</div>
<div class="s1-step-line"></div>
<!-- Step 3: 앱 생성 완료 (rocket icon) -->
<div class="s1-step">
<div class="s1-step-circle">
<svg class="s1-step-svg" width="26" height="26" viewBox="0 0 24 24" fill="none"
xmlns="http://www.w3.org/2000/svg">
<path
d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z"
stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" />
<path
d="M12 15l-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z"
stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" />
<path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0" stroke="currentColor" stroke-width="1.6"
stroke-linecap="round" stroke-linejoin="round" />
<path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5" stroke="currentColor" stroke-width="1.6"
stroke-linecap="round" stroke-linejoin="round" />
</svg>
</div>
<span class="s1-step-num">3단계</span>
<span class="s1-step-name">클라이언트 신청 완료</span>
</div>
</div>
</div>
<!-- Error -->
<div th:if="${error}" class="s1-alert">
<span th:text="${error}"></span>
</div>
<!-- Form Card -->
<div class="s1-form-card">
<form id="registerStep1Form" method="post" th:action="@{/clients/register/step1}"
th:object="${apiKeyRegistration}" enctype="multipart/form-data">
<!-- 앱 이름 -->
<div class="s1-field">
<label class="s1-label">앱 이름 <span class="s1-required">*</span></label>
<input type="text" id="appName" name="appName" th:field="*{appName}" class="s1-input"
placeholder="앱 이름을 입력하세요." required maxlength="100">
</div>
<!-- 앱 설명 -->
<div class="s1-field">
<label class="s1-label">앱 설명 <span class="s1-required">*</span></label>
<textarea id="appDescription" name="appDescription" th:field="*{appDescription}" class="s1-textarea"
placeholder="앱 설명을 입력하세요." required maxlength="500"></textarea>
<div class="s1-counter-row">
<span id="charCounter" class="s1-counter">0 / 200</span>
</div>
</div>
<!--/* Call Back URL : OAuth2 3-legged 전용. 현재 미사용으로 주석 처리 (추후 OAuth2 도입 시 복원)
<div class="s1-field">
<label class="s1-label">Call Back URL</label>
<input type="text" id="callbackUrl" name="callbackUrl" th:field="*{callbackUrl}" class="s1-input"
placeholder="URL을 입력해 주세요.">
</div>
*/-->
<!-- 화이트 리스트 -->
<div class="s1-field">
<label class="s1-label">화이트 리스트 <span class="s1-required">*</span></label>
<!-- 입력행 -->
<div class="s1-ip-row">
<input type="text" id="ipWhitelistInput" class="s1-input" placeholder="서버 IP(111.111.111.111)">
<button type="button" class="s1-ip-btn s1-ip-btn--add" onclick="addIpAddress()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2.5">
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
추가
</button>
</div>
<!-- 추가된 IP 목록 -->
<div id="ipList" style="display:none;">
<div id="ipItems"></div>
</div>
<input type="hidden" id="ipWhitelist" name="ipWhitelist" value="">
</div>
<!-- 앱 아이콘 (선택 옵션 — 최하단 배치, compact) -->
<div class="s1-field">
<label class="s1-label">앱 아이콘</label>
<div class="s1-upload-box s1-upload-box--compact" id="iconDropZone">
<img id="iconPreviewImage" class="s1-preview-img" style="display:none;" alt="미리보기">
<div id="iconPlaceholder" class="s1-upload-inner">
<img th:src="@{/img/icon/img_icon.png}">
<p class="s1-upload-title">앱 아이콘 이미지 파일을 업로드 하세요</p>
<p class="s1-upload-hint">권장 사이즈는 512 * 512 픽셀이며 JPG,PNG,GIF 파일만 등록할 수 있습니다.</p>
<button type="button" class="s1-btn-upload"
onclick="document.getElementById('appIconFile').click()">
이미지 파일 업로드
</button>
</div>
<button type="button" class="s1-btn-remove-icon" id="btnRemoveIcon" style="display:none;"
onclick="removeAppIcon()"></button>
<input type="file" id="appIconFile" name="appIcon" accept="image/png,image/jpeg,image/jpg,image/gif"
style="display:none;">
</div>
</div>
</form>
</div>
<!-- 다음 버튼 -->
<div class="s1-actions">
<button type="submit" form="registerStep1Form" class="s1-btn-next">다음</button>
</div>
</div><!-- /step1-wrap -->
</div>
</div>
</div>
</th:block>
<th:block layout:fragment="contentScript">
<script th:inline="javascript">
let ipAddresses = [];
function removeAppIcon() {
const fileInput = document.getElementById('appIconFile');
const preview = document.getElementById('iconPreviewImage');
const placeholder = document.getElementById('iconPlaceholder');
const removeBtn = document.getElementById('btnRemoveIcon');
fileInput.value = '';
preview.style.display = 'none';
preview.src = '';
placeholder.style.display = 'flex';
removeBtn.style.display = 'none';
}
function addIpAddress(refocus) {
if (refocus === undefined) refocus = true;
const ipInput = document.getElementById('ipWhitelistInput');
const ipValue = ipInput.value.trim();
if (!ipValue) {
customPopups.showAlert('IP 주소를 입력해주세요.');
return;
}
const ipPattern = /^(\d{1,3}\.){3}\d{1,3}(\/\d{1,2})?$/;
if (!ipPattern.test(ipValue)) {
customPopups.showAlert('올바른 IP 주소 형식이 아닙니다.\n예: 192.168.1.1 또는 10.0.0.0/24');
return;
}
if (ipAddresses.includes(ipValue)) {
customPopups.showAlert('이미 추가된 IP 주소입니다.');
return;
}
ipAddresses.push(ipValue);
renderIpList();
ipInput.value = '';
if (refocus) ipInput.focus();
}
function removeIpAddress(ip) {
ipAddresses = ipAddresses.filter(function (i) { return i !== ip; });
renderIpList();
}
function renderIpList() {
const ipList = document.getElementById('ipList');
const ipItems = document.getElementById('ipItems');
const hiddenInput = document.getElementById('ipWhitelist');
if (ipAddresses.length > 0) {
ipList.style.display = 'block';
ipItems.innerHTML = ipAddresses.map(function (ip) {
return `<div class="s1-ip-item">`
+ `<div class="s1-ip-display">${ip}</div>`
+ `<button type="button" class="s1-ip-btn s1-ip-btn--remove" onclick="removeIpAddress('${ip}')">`
+ `<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M19 11H5v2h14z"/></svg>`
+ `제거</button>`
+ `</div>`;
}).join('');
} else {
ipList.style.display = 'none';
ipItems.innerHTML = '';
}
hiddenInput.value = ipAddresses.join(',');
}
document.addEventListener('DOMContentLoaded', function () {
// URL param clear
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.get('clear') === 'true') {
Object.keys(sessionStorage)
.filter(function (k) { return k.startsWith('registration_'); })
.forEach(function (k) { sessionStorage.removeItem(k); });
}
const form = document.getElementById('registerStep1Form');
const textarea = document.getElementById('appDescription');
const counter = document.getElementById('charCounter');
const fileInput = document.getElementById('appIconFile');
const preview = document.getElementById('iconPreviewImage');
const placeholder = document.getElementById('iconPlaceholder');
const ipInput = document.getElementById('ipWhitelistInput');
// Thymeleaf: Restore IP whitelist
/*<![CDATA[*/
const sessionIpWhitelist = /*[[${apiKeyRegistration.ipWhitelistAsString}]]*/ '';
if (sessionIpWhitelist && sessionIpWhitelist.trim() !== '') {
sessionIpWhitelist.split(',').forEach(function (ip) {
const t = ip.trim();
if (t && !ipAddresses.includes(t)) ipAddresses.push(t);
});
renderIpList();
}
// Thymeleaf: Restore icon
const appIconFileName = /*[[${apiKeyRegistration.appIconFileName}]]*/ '';
const appIconBase64 = /*[[${apiKeyRegistration.appIconBase64}]]*/ '';
if (appIconFileName && appIconFileName.trim() !== '' && appIconBase64) {
preview.src = appIconBase64;
preview.style.display = 'block';
placeholder.style.display = 'none';
document.getElementById('btnRemoveIcon').style.display = 'flex';
fileInput.removeAttribute('required');
}
/*]]>*/
// Char counter init
if (textarea && counter) {
const max = textarea.getAttribute('maxlength') || 500;
counter.textContent = textarea.value.length + ' / ' + max;
textarea.addEventListener('input', function () {
counter.textContent = this.value.length + ' / ' + max;
counter.style.color = this.value.length > max * 0.9 ? '#f4253c' : '#94a3b8';
});
}
// File input change
fileInput.addEventListener('change', function (e) {
const file = e.target.files[0];
if (!file) return;
if (file.size > 2 * 1024 * 1024) {
customPopups.showAlert('파일 크기는 2MB를 초과할 수 없습니다.');
this.value = '';
return;
}
if (!['image/png', 'image/jpeg', 'image/jpg', 'image/gif'].includes(file.type)) {
customPopups.showAlert('PNG, JPG, GIF 형식만 업로드 가능합니다.');
this.value = '';
return;
}
const reader = new FileReader();
reader.onload = function (ev) {
preview.src = ev.target.result;
preview.style.display = 'block';
placeholder.style.display = 'none';
document.getElementById('btnRemoveIcon').style.display = 'flex';
fileInput.removeAttribute('required');
};
reader.readAsDataURL(file);
});
// Drag & drop
const dropZone = document.getElementById('iconDropZone');
if (dropZone) {
dropZone.addEventListener('dragover', function (e) {
e.preventDefault();
this.classList.add('drag-over');
});
dropZone.addEventListener('dragleave', function () {
this.classList.remove('drag-over');
});
dropZone.addEventListener('drop', function (e) {
e.preventDefault();
this.classList.remove('drag-over');
if (e.dataTransfer.files.length > 0) {
fileInput.files = e.dataTransfer.files;
fileInput.dispatchEvent(new Event('change'));
}
});
}
// IP input Enter/Tab key — 추가 버튼을 지나쳐도 입력값을 등록한다.
// Tab 은 다음 tab-stop(추가 버튼)으로 포커스만 이동(onclick 미발동)하므로
// keydown 에서 직접 추가한다. blur 보다 먼저 실행되어 중복 없이 처리된다.
ipInput.addEventListener('keydown', function (e) {
if (e.key === 'Enter') { e.preventDefault(); addIpAddress(); return; }
if (e.key === 'Tab' && ipInput.value.trim()) { addIpAddress(false); }
});
// 포커스 아웃 시 자동 추가/검증 (사용자가 "추가" 버튼을 지나치는 경우 대응).
// "추가" 버튼으로 포커스 이동 시엔 버튼 onclick 이 처리하므로 중복 추가 방지.
ipInput.addEventListener('blur', function (e) {
const rt = e.relatedTarget;
if (rt && rt.closest && rt.closest('.s1-ip-btn--add')) return;
if (!ipInput.value.trim()) return; // 빈 값은 무시
addIpAddress(false); // blur 경로: 재포커스 안 함
});
// Form submit validation
form.addEventListener('submit', function (e) {
const name = document.getElementById('appName').value.trim();
const desc = textarea.value.trim();
// callbackUrl: OAuth2 3-legged 전용. 현재 미사용으로 주석 처리.
// const url = document.getElementById('callbackUrl').value.trim();
if (!name) {
e.preventDefault();
customPopups.showAlert('앱 이름을 입력해주세요.');
document.getElementById('appName').focus();
return;
}
if (!desc) {
e.preventDefault();
customPopups.showAlert('앱 설명을 입력해주세요.');
textarea.focus();
return;
}
// 화이트리스트 필수
if (ipAddresses.length === 0) {
e.preventDefault();
customPopups.showAlert('화이트리스트 IP를 1개 이상 등록해주세요.');
document.getElementById('ipWhitelistInput').focus();
return;
}
// callbackUrl 검증: OAuth2 3-legged 전용. 현재 미사용으로 주석 처리.
// if (url) {
// try { new URL(url); } catch (_) {
// e.preventDefault();
// customPopups.showAlert('올바른 URL 형식이 아닙니다.\n예: https://example.com/callback');
// document.getElementById('callbackUrl').focus();
// return;
// }
// }
sessionStorage.setItem('registration_appName', name);
sessionStorage.setItem('registration_appDescription', desc);
// sessionStorage.setItem('registration_callbackUrl', url);
sessionStorage.setItem('registration_ipWhitelist', ipAddresses.join(','));
});
});
</script>
</th:block>
</body>
</html>