전역 토스트 알림 추가 및 UI/로직 개선:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled

- 전역 토스트 알림용 JS 및 SCSS 추가 (djbToast)
- 신규 토스트 연동 레이아웃(js 포함) 및 스타일 확장 대응
- 클라이언트 신청/수정 화면 개선, IP 등록 검증 로직 추가
This commit is contained in:
Rinjae
2026-07-29 15:32:05 +09:00
parent 929980c089
commit c02a5ff047
31 changed files with 675 additions and 93 deletions
@@ -64,6 +64,12 @@ public class ApiController {
Map<String, Object> searchResult = apiSearchFacade.searchApis(new ApiGroupSearch());
// 상세 타이틀에 노출할 현재 API의 그룹명 세팅(selectDetail은 apiGroupName을 채우지 않음)
ApiServiceDTO apiGroup = apiServiceService.findApiServiceByApiId(id);
if (apiGroup != null) {
api.setApiGroupName(apiGroup.getGroupName());
}
model.addAttribute("apiSpecInfo", api);
model.addAttribute("totalApiCount", searchResult.get("totalApiCount"));
model.addAttribute("services", searchResult.get("services"));
+1 -1
View File
@@ -366,7 +366,7 @@ page:
name: "개발자 정보"
path: "/users/detail"
apikey:
name: " 관리"
name: "API 신청 관리"
path: "/myapikey"
app_request_detail:
name: "인증키 신청 상세"
+165 -2
View File
@@ -6950,6 +6950,79 @@ select.form-control {
color: #c3dfea;
}
.djb-toast-container {
position: fixed;
top: 24px;
left: 50%;
transform: translateX(-50%);
z-index: 12000;
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
pointer-events: none;
}
@media (max-width: 768px) {
.djb-toast-container {
top: 12px;
left: 12px;
right: 12px;
transform: none;
align-items: stretch;
}
}
.djb-toast {
pointer-events: auto;
min-width: 260px;
max-width: 380px;
padding: 14px 18px;
border-radius: 10px;
background: #f4f7fc;
color: #1f2328;
font-size: 14px;
line-height: 1.5;
border: 1px solid #e3e8f0;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
cursor: pointer;
opacity: 0;
transform: translateY(-12px);
transition: opacity 0.22s ease, transform 0.22s ease;
}
.djb-toast.is-show {
opacity: 1;
transform: translateY(0);
}
.djb-toast {
border-left: 4px solid #0049B4;
}
.djb-toast--info {
border-left-color: #0049B4;
}
.djb-toast--success {
border-left-color: #1f883d;
}
.djb-toast--warn {
border-left-color: #bf8700;
}
.djb-toast--error {
border-left-color: #cf222e;
}
@media (max-width: 768px) {
.djb-toast {
max-width: none;
}
}
@media (min-width: 769px) {
.djb-toast {
min-width: 338px;
max-width: 494px;
padding: 18px 24px;
font-size: 18px;
border-radius: 13px;
}
}
.pagination {
display: flex;
align-items: center;
@@ -11297,9 +11370,9 @@ body.index-page-body {
margin: 8px 0 0;
padding: 24px;
background: #FFFFFF;
border: 1px solid #E2E8F0;
border: none;
border-radius: 12px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
box-shadow: none;
}
.testbed-app-panel .testbed-app-panel__head {
display: flex;
@@ -11404,6 +11477,22 @@ body.index-page-body {
}
}
/* "기본 정보" 헤더: 좌측 제목 + 우측 "API 사용 신청" 버튼 */
.api-basic-info-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
}
/* 하단 중앙 "API 사용 신청" 버튼 */
.api-apply-footer {
display: flex;
justify-content: center;
margin-top: 32px;
}
.api-overview-card {
background: transparent;
border-radius: 0;
@@ -16412,6 +16501,38 @@ input[type=checkbox]:checked + .custom-checkbox {
border-color: #2a69de;
background: #f0f7ff;
}
.step1-wrap .s1-upload-box--compact,
.step2-wrap .s1-upload-box--compact,
.step3-wrap .s1-upload-box--compact {
min-height: 96px;
}
.step1-wrap .s1-upload-box--compact .s1-upload-inner,
.step2-wrap .s1-upload-box--compact .s1-upload-inner,
.step3-wrap .s1-upload-box--compact .s1-upload-inner {
flex-direction: row;
flex-wrap: wrap;
justify-content: center;
gap: 6px 14px;
padding: 12px 16px;
}
.step1-wrap .s1-upload-box--compact .s1-upload-inner > img,
.step2-wrap .s1-upload-box--compact .s1-upload-inner > img,
.step3-wrap .s1-upload-box--compact .s1-upload-inner > img {
width: 28px;
height: 28px;
}
.step1-wrap .s1-upload-box--compact .s1-upload-title,
.step2-wrap .s1-upload-box--compact .s1-upload-title,
.step3-wrap .s1-upload-box--compact .s1-upload-title {
font-size: 13px;
}
.step1-wrap .s1-upload-box--compact .s1-btn-upload,
.step2-wrap .s1-upload-box--compact .s1-btn-upload,
.step3-wrap .s1-upload-box--compact .s1-btn-upload {
height: 34px;
padding: 0 16px;
font-size: 13px;
}
.step1-wrap .s1-upload-inner,
.step2-wrap .s1-upload-inner,
.step3-wrap .s1-upload-inner {
@@ -23924,6 +24045,42 @@ input[type=checkbox]:checked + .custom-checkbox {
justify-content: center;
margin-top: 20px;
}
.signup-guide-v2 .signup-roles-tbd {
margin-top: 40px;
}
.signup-guide-v2 .signup-roles-tbd__title {
font-size: 20px;
font-weight: 700;
color: #140064;
margin: 0 0 16px;
}
.signup-guide-v2 .signup-roles-tbd__placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 48px 24px;
background: #f8f9fa;
border: 1px dashed #e3e8f0;
border-radius: 12px;
text-align: center;
}
.signup-guide-v2 .signup-roles-tbd__badge {
display: inline-block;
padding: 4px 12px;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.04em;
color: #0049B4;
background: #eff9fe;
border-radius: 20px;
}
.signup-guide-v2 .signup-roles-tbd__desc {
margin: 0;
font-size: 15px;
color: #6e7781;
}
@media (max-width: 1024px) {
.service-hero {
@@ -24765,6 +24922,12 @@ input[type=checkbox]:checked + .custom-checkbox {
color: #4a5568;
}
.statistics-notice-example {
flex-basis: 100%; /* flex-wrap 컨테이너에서 두 번째 줄로 강제 */
color: #6b7280;
font-size: 12px;
}
.statistics-notice-latest {
color: #0049B4;
font-weight: 600;
File diff suppressed because one or more lines are too long
+79
View File
@@ -0,0 +1,79 @@
/*!
* DjbToast — 경량 전역 토스트 알림.
* 사용: window.djbToast('메시지') 또는 djbToast('메시지', { type: 'info', duration: 3000 })
*
* 추가로, 페이지 진입 시 URL 쿼리 `apiApplyToast` 값에 따라 안내 토스트를 자동 노출한다.
* - new : 신규 클라이언트 생성 페이지 진입 안내
* - existing : 클라이언트 목록(선택) 진입 안내
* 토스트 노출 후 쿼리를 정리(replaceState)해 새로고침 시 재노출을 막는다.
*/
(function (global) {
"use strict";
var CONTAINER_ID = "djb-toast-container";
function ensureContainer() {
var el = document.getElementById(CONTAINER_ID);
if (!el) {
el = document.createElement("div");
el.id = CONTAINER_ID;
el.className = "djb-toast-container";
document.body.appendChild(el);
}
return el;
}
function showToast(message, opts) {
if (!message) return;
opts = opts || {};
var duration = typeof opts.duration === "number" ? opts.duration : 3200;
var type = opts.type || "info";
var container = ensureContainer();
var toast = document.createElement("div");
toast.className = "djb-toast djb-toast--" + type;
toast.setAttribute("role", "status");
toast.innerHTML = String(message); // 호출부에서만 신뢰 문자열 전달
container.appendChild(toast);
// enter 애니메이션
requestAnimationFrame(function () { toast.classList.add("is-show"); });
var remove = function () {
toast.classList.remove("is-show");
setTimeout(function () {
if (toast.parentNode) toast.parentNode.removeChild(toast);
}, 250);
};
var timer = setTimeout(remove, duration);
toast.addEventListener("click", function () { clearTimeout(timer); remove(); });
}
global.djbToast = showToast;
// ── URL 쿼리 기반 자동 안내 토스트 ──
var API_APPLY_MESSAGES = {
"new": "API 를 사용할 클라이언트 생성을 먼저 진행해주세요.",
"existing": "API 를 추가 사용할 클라이언트를 선택해주세요"
};
function handleApiApplyToast() {
var params = new URLSearchParams(global.location.search);
var key = params.get("apiApplyToast");
if (!key || !API_APPLY_MESSAGES[key]) return;
showToast(API_APPLY_MESSAGES[key], { type: "info" });
// 쿼리 제거 후 URL 정리 (새로고침 재노출 방지)
params.delete("apiApplyToast");
var qs = params.toString();
var newUrl = global.location.pathname + (qs ? "?" + qs : "") + global.location.hash;
try { global.history.replaceState(null, "", newUrl); } catch (e) { /* noop */ }
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", handleApiApplyToast);
} else {
handleApiApplyToast();
}
})(window);
@@ -53,7 +53,7 @@
'op.tryItOut': { en: 'Try it out', ko: '실행해보기' },
'op.cancel': { en: 'Cancel', ko: '취소' },
'op.execute': { en: 'Execute', ko: '실행' },
'op.clear': { en: 'Clear', ko: '지우기' },
'op.clear': { en: 'Clear', ko: '응답 지우기' },
'op.reset': { en: 'Reset', ko: '초기화' },
'op.parameters': { en: 'Parameters', ko: '파라미터' },
'op.parameter': { en: 'Parameter', ko: '파라미터' },
@@ -225,6 +225,18 @@
+ '</div>';
}
// Execute 후 응답(또는 검증 실패) 영역으로 자동 스크롤. 성공/검증실패 모두
// .djb-response-grid 로 수렴하므로 이 그리드를 뷰포트 상단으로 올린다.
function scrollToGrid(op) {
setTimeout(function () {
var grid = (op || document).querySelector(".djb-response-grid")
|| document.querySelector("#swagger-ui .djb-response-grid");
if (!grid) return;
try { grid.scrollIntoView({ behavior: "smooth", block: "start" }); }
catch (e) { grid.scrollIntoView(); }
}, 60);
}
// Execute 클릭 시점에 대상 opblock 확정 + (검증) + 타이머 시작 + 빈 그리드 표시.
// 캡처 단계라 Swagger(React) 의 실행 핸들러보다 먼저 실행됨 → 검증 실패 시
// stopImmediatePropagation 으로 native 실행을 차단하고 에러만 렌더한다.
@@ -247,6 +259,7 @@
e.preventDefault();
e.stopImmediatePropagation();
renderValidationErrors(result.errors);
scrollToGrid(targetOpblock);
return;
}
}
@@ -261,6 +274,7 @@
grid.classList.remove("djb-error");
grid.innerHTML = renderEmpty();
}
scrollToGrid(targetOpblock);
}
/**
@@ -225,6 +225,15 @@
withObserverPaused(function () { renderInto(panelEl()); });
return;
}
// Execute(실행) 클릭 시: Swagger 가 요청 DOM/값을 갱신한 뒤 현재 탭을 재렌더.
// (검증 실패는 response.js 캡처가 stopImmediatePropagation 으로 차단 → 여기 도달 안 함)
// Swagger 의 opblock-body 재렌더 타이밍이 유동적이라 짧게 두 번 갱신한다.
var exec = e.target.closest && e.target.closest(".btn.execute");
if (exec) {
setTimeout(function () { withObserverPaused(function () { renderInto(panelEl()); }); }, 80);
setTimeout(function () { withObserverPaused(function () { renderInto(panelEl()); }); }, 300);
return;
}
var copy = e.target.closest && e.target.closest('#djb-snippet-panel [data-action="copy"]');
if (copy) {
var code = document.querySelector("#djb-snippet-panel .djb-sn-code");
@@ -41,6 +41,40 @@
.swagger-ui .responses-inner > div { display: none !important; }
.swagger-ui .live-responses-table { display: none !important; }
/* ─── 서버 영역(.scheme-container) ─────────────────────────────────────────────
"서버" 셀렉트박스(라벨+select)는 숨긴다 — 요청 URL 은 spec servers[0] 값을 그대로
사용하므로 UI 만 제거해도 호출에는 영향 없음. 대신 이 영역 안으로 "앱 선택" 패널
(#appsWrap)을 JS 로 이관(relocate)해 배치한다. */
.swagger-ui .scheme-container .servers-title,
.swagger-ui .scheme-container .servers,
.swagger-ui .scheme-container .schemes-server-container { display: none !important; }
/* 앱 선택(#appsWrap)과 인증(.auth-wrapper) 을 한 행에 8:2 로 배치 */
.swagger-ui .scheme-container .schemes {
flex-wrap: nowrap;
align-items: stretch;
gap: 16px;
}
.swagger-ui .scheme-container #appsWrap {
order: 1;
flex: 8 1 0;
min-width: 0; /* select 축약(truncate) 위해 shrink 허용 */
margin: 0;
}
.swagger-ui .scheme-container .auth-wrapper {
order: 2;
flex: 2 1 0;
min-width: 0;
align-items: center;
justify-content: stretch;
}
.swagger-ui .scheme-container .auth-wrapper .authorize {
width: 100%;
justify-content: center;
margin: 0;
padding-right: 0;
}
/* ─── 상시 요청 스니펫 패널 (PoC overlay.css #djb-snippet-panel) ─────────────── */
.swagger-ui #djb-snippet-panel {
margin: 16px;
@@ -0,0 +1,68 @@
// ── DjbToast (전역 토스트 알림) ────────────────────────────────
// js/djb/toast.js 와 연동. 화면 우하단 스택 노출.
.djb-toast-container {
position: fixed;
top: 24px;
left: 50%;
transform: translateX(-50%);
z-index: 12000;
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
pointer-events: none;
@media (max-width: 768px) {
top: 12px;
left: 12px;
right: 12px;
transform: none;
align-items: stretch;
}
}
.djb-toast {
pointer-events: auto;
min-width: 260px;
max-width: 380px;
padding: 14px 18px;
border-radius: 10px;
background: #f4f7fc;
color: #1f2328;
font-size: 14px;
line-height: 1.5;
border: 1px solid #e3e8f0;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
cursor: pointer;
opacity: 0;
transform: translateY(-12px);
transition: opacity 0.22s ease, transform 0.22s ease;
&.is-show {
opacity: 1;
transform: translateY(0);
}
// 좌측 강조 바
border-left: 4px solid #0049B4;
&--info { border-left-color: #0049B4; }
&--success { border-left-color: #1f883d; }
&--warn { border-left-color: #bf8700; }
&--error { border-left-color: #cf222e; }
@media (max-width: 768px) {
max-width: none;
}
// 데스크탑: 크기 약 30% 확대 (가독성)
@media (min-width: 769px) {
min-width: 338px;
max-width: 494px;
padding: 18px 24px;
font-size: 18px;
border-radius: 13px;
}
}
+1
View File
@@ -40,6 +40,7 @@
@use 'components/accordion' as *;
@use 'components/page-title-banner' as *;
@use 'components/alerts' as *;
@use 'components/toast' as *;
@use 'components/pagination' as *;
@use 'components/breadcrumb' as *;
@use 'components/test-env-notice' as *;
@@ -726,9 +726,9 @@
margin: $spacing-sm 0 0;
padding: $spacing-lg;
background: $white;
border: 1px solid $border-gray;
border: none;
border-radius: $border-radius-lg;
box-shadow: $shadow-sm;
box-shadow: none;
.testbed-app-panel__head {
display: flex;
@@ -846,6 +846,22 @@
}
// API Overview Card (Flat Style)
/* "기본 정보" 헤더: 좌측 제목 + 우측 "API 사용 신청" 버튼 */
.api-basic-info-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: $spacing-md;
flex-wrap: wrap;
}
/* 하단 중앙 "API 사용 신청" 버튼 */
.api-apply-footer {
display: flex;
justify-content: center;
margin-top: $spacing-xl;
}
.api-overview-card {
background: transparent;
border-radius: 0;
@@ -25,6 +25,12 @@
color: #4a5568;
}
.statistics-notice-example {
flex-basis: 100%; /* flex-wrap 컨테이너에서 두 번째 줄로 강제 */
color: #6b7280;
font-size: 12px;
}
.statistics-notice-latest {
color: #0049B4;
font-weight: 600;
@@ -3249,6 +3249,34 @@ input[type="checkbox"]:checked+.custom-checkbox {
}
}
// 선택 옵션 필드용 compact 변형: 높이 축소 + 가로 배치
.s1-upload-box--compact {
min-height: 96px;
.s1-upload-inner {
flex-direction: row;
flex-wrap: wrap;
justify-content: center;
gap: 6px 14px;
padding: 12px 16px;
}
.s1-upload-inner > img {
width: 28px;
height: 28px;
}
.s1-upload-title {
font-size: 13px;
}
.s1-btn-upload {
height: 34px;
padding: 0 16px;
font-size: 13px;
}
}
.s1-upload-inner {
display: flex;
flex-direction: column;
@@ -554,6 +554,48 @@ $service-card-shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgb
justify-content: center;
margin-top: 20px;
}
// 권한별 이용 가능 서비스 (TBD placeholder)
.signup-roles-tbd {
margin-top: 40px;
&__title {
font-size: 20px;
font-weight: 700;
color: #140064;
margin: 0 0 16px;
}
&__placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 48px 24px;
background: #f8f9fa;
border: 1px dashed #e3e8f0;
border-radius: 12px;
text-align: center;
}
&__badge {
display: inline-block;
padding: 4px 12px;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.04em;
color: #0049B4;
background: #eff9fe;
border-radius: 20px;
}
&__desc {
margin: 0;
font-size: 15px;
color: #6e7781;
}
}
}
// Responsive
@@ -48,7 +48,7 @@
<!-- Header -->
<div class="api-market-header">
<div class="api-market-title">
<h1>서비스</h1>
<h1 th:text="${apiSpecInfo.apiGroupName}">그룹명</h1>
<h2 th:text="${apiSpecInfo.apiName}">API 이름</h2>
</div>
</div>
@@ -69,8 +69,9 @@
<div class="tab-content" th:classappend="${activeTab == 'testbed'} ? '' : 'active'" id="api-info-tab">
<!-- API Overview Card (Merged with Additional Information) -->
<div class="api-overview-card">
<div class="org-section-header org-section-header--agreement">
<div class="org-section-header org-section-header--agreement api-basic-info-header">
<h3>기본 정보</h3>
<button type="button" class="btn-action-primary md api-apply-btn">API 사용 신청</button>
</div>
<div class="api-overview-header">
<div class="api-method-badge" th:text="${apiSpecInfo.apiMethod}">GET</div>
@@ -142,6 +143,11 @@
</div>
</div>
</div>
<!-- 하단 중앙 API 사용 신청 -->
<div class="api-apply-footer">
<button type="button" class="btn-action-primary api-apply-btn">API 사용 신청</button>
</div>
</div>
<!-- Testbed Tab Content -->
@@ -364,6 +370,8 @@
onComplete: function() {
// 단일 API 페이지 — 상시 요청 스니펫 패널 마운트
setTimeout(function() { try { window.DjbSwaggerSnippetPanel.mount({ rootSel: '#swagger-ui' }); } catch (e) { console.error(e); } }, 50);
// "앱 선택" 패널을 Swagger 서버 영역(.scheme-container) 안으로 이관
setTimeout(function() { try { relocateAppPanel(); } catch (e) { console.error(e); } }, 50);
},
showMutatedRequest: false,
validatorUrl: '',
@@ -411,10 +419,80 @@
});
}
// ===== API 사용 신청: 회원 구분별 분기 (API 신청 절차) =====
// 비회원/개인회원 → 법인회원 안내 팝업 → 회원가입 안내 이동
// 법인이용자 → 클라이언트 현황: 신규(앱 0건)→신규 클라이언트 / 기존(앱 있음)→클라이언트 관리
function requestApiUse() {
fetch(/*[[@{/djb/testbed/auth/context}]]*/ '/djb/testbed/auth/context')
.then(function (r) { return r.json(); })
.then(function (ctx) {
const reason = ctx && ctx.reason;
if (reason === 'ANONYMOUS' || reason === 'INDIVIDUAL_USER') {
const go = function () { window.location.href = /*[[@{/service/guide}]]*/ '/service/guide'; };
// custom-popups.js 는 `const customPopups`(전역 렉시컬) 로 노출 — window 프로퍼티 아님.
if (typeof customPopups !== 'undefined' && customPopups.showAlert) {
customPopups.showAlert(
'API 사용 신청은 법인회원(법인관리자·법인개발자)만 이용할 수 있습니다.<br>회원 가입 안내 페이지로 이동합니다.',
go
);
} else { go(); }
return;
}
// 법인이용자: 보유 클라이언트 유무로 신규/기존 분기 (이동 페이지에서 Toast 안내)
const hasClients = ctx && ctx.credentials && ctx.credentials.length > 0;
if (hasClients) {
// 기존 → 클라이언트 목록(선택) + Toast
window.location.href = /*[[@{/myapikey(apiApplyToast='existing')}]]*/ '/myapikey?apiApplyToast=existing';
return;
}
// 신규(클라이언트 0) → 생성 필요 안내 확인 후 이동
const goNew = function () {
window.location.href = /*[[@{/myapikey/register/step1(clear=true,apiApplyToast='new')}]]*/ '/myapikey/register/step1?clear=true&apiApplyToast=new';
};
if (typeof customPopups !== 'undefined' && customPopups.showConfirm) {
customPopups.showConfirm(
'API 를 이용하려면 신규 클라이언트 생성이 필요합니다.<br>클라이언트 생성 페이지로 이동하시겠습니까?',
function (ok) { if (ok) goNew(); }
);
} else { goNew(); }
})
.catch(function (e) {
console.error('API 사용 신청 컨텍스트 조회 실패', e);
window.location.href = /*[[@{/myapikey}]]*/ '/myapikey';
});
}
Array.prototype.forEach.call(document.querySelectorAll('.api-apply-btn'), function (btn) {
btn.addEventListener('click', requestApiUse);
});
// ===== DJPGPT0001: 앱 인증 정보 자동 주입 =====
const DEFAULT_TOKEN_API_ID = 'default-token-api-spec';
const appsSelect = document.getElementById('apps');
// "앱 선택" 패널(#appsWrap)을 Swagger 서버 영역(.scheme-container) 안으로 이관.
// 서버 셀렉트박스는 CSS 로 숨기고, 그 자리에 앱 선택을 배치한다.
// SwaggerUI 가 scheme-container 를 재렌더하면 노드가 이탈할 수 있어 observer 로 재고정.
function relocateAppPanel() {
const panel = document.getElementById('appsWrap');
if (!panel) return;
const scheme = document.querySelector('#swagger-ui .scheme-container');
if (!scheme) return;
const host = scheme.querySelector('.schemes') || scheme;
if (panel.parentNode !== host) host.appendChild(panel);
}
// scheme-container 재렌더 대비: #swagger-ui 변경 감지 시 재이관(멱등)
(function watchAppPanelRelocation() {
const root = document.getElementById('swagger-ui');
if (!root) return;
const obs = new MutationObserver(function () {
const panel = document.getElementById('appsWrap');
const host = document.querySelector('#swagger-ui .scheme-container .schemes')
|| document.querySelector('#swagger-ui .scheme-container');
if (panel && host && panel.parentNode !== host) host.appendChild(panel);
});
obs.observe(root, { childList: true, subtree: true });
})();
function eligibilityMessage(reason) {
switch (reason) {
case 'ANONYMOUS': return '로그인 후 본인 앱의 인증 정보를 사용할 수 있습니다.';
@@ -438,10 +516,17 @@
}
appsSelect.disabled = false;
if (notice) notice.style.display = 'none';
// 표시용 client id 축약: 앞 5글자 + "..." (value 는 full 유지 → 인증에 사용)
const abbrevId = function (id) {
id = id || '';
return id.length > 5 ? (id.slice(0, 5) + '...') : id;
};
(ctx.credentials || []).forEach(function (c) {
const opt = document.createElement('option');
opt.value = c.clientId;
opt.textContent = c.clientName ? (c.clientName + ' (' + c.clientId + ')') : c.clientId;
opt.textContent = c.clientName
? (c.clientName + ' (' + abbrevId(c.clientId) + ')')
: abbrevId(c.clientId);
appsSelect.appendChild(opt);
});
})
@@ -17,7 +17,7 @@
<p class="hero-subtitle">세상의 모든 서비스</p>
<h2 class="hero-title">DJBank API가<br>함께 합니다.</h2>
</div>
<a href="#" class="btn-hero-signup">회원가입하기 <i class="bi bi-chevron-right"></i></a>
<a href="#" class="btn-hero-signup">회원 가입 안내 <i class="bi bi-chevron-right"></i></a>
</div>
<div class="hero-image-content">
<!-- Inline SVG Tech Illustration -->
@@ -327,7 +327,7 @@
DJBank API Portal은 기업이 혁신적인 금융 서비스를 쉽고 신속하게 개발하고 구현할 수 있도록 엄선된 API 명세와 직관적인 샌드박스 테스트 환경을 무상으로 지원합니다.
</p>
<div class="action-buttons">
<a href="#" class="action-btn btn-secondary">처음 만나는 DJBank API</a>
<a th:href="@{/service/intro}" class="action-btn btn-secondary">처음 만나는 DJBank API</a>
<a href="#" class="action-btn btn-primary">피드백 / 개선요청 <i class="bi bi-patch-question"></i></a>
</div>
</div>
@@ -21,10 +21,10 @@
<div class="app-management-content">
<!-- Header -->
<div class="app-management-header">
<h2>인증 키 관리</h2>
<h2>클라이언트/API 신청 관리</h2>
<div sec:authorize="hasRole('ROLE_API_KEY_REQUEST')">
<button type="button" class="btn-action-primary md" id="requestApiKey">
생성
클라이언트 생성
</button>
</div>
</div>
@@ -22,7 +22,7 @@
<div class="step1-wrap">
<!-- Title -->
<h2 class="s1-title"> 수정</h2>
<h2 class="s1-title">클라이언트 정보 - 수정</h2>
<!-- Progress Steps Card -->
<div class="s1-progress-card">
@@ -71,7 +71,7 @@
</svg>
</div>
<span class="s1-step-num">3단계</span>
<span class="s1-step-name">완료</span>
<span class="s1-step-name">클라이언트 신청 완료</span>
</div>
</div>
@@ -90,42 +90,6 @@
<!-- Hidden clientId -->
<input type="hidden" th:field="*{clientId}"/>
<!-- 앱 아이콘 -->
<div class="s1-field">
<label class="s1-label">앱 아이콘</label>
<div class="s1-upload-box" id="iconDropZone">
<!-- 기존 파일 ID로 이미지 노출 -->
<img class="s1-preview-img" id="iconPreviewImage1"
th:if="${apiKeyModification.appIconFileId != null}"
th:src="@{/file/download(fileSn=1,fileId=${apiKeyModification.appIconFileId})}"
alt="미리보기" style="display: block;">
<!-- 데이터에서 이미지 노출 -->
<img class="s1-preview-img" id="iconPreviewImage2"
th:if="${apiKeyModification.appIconData != null and apiKeyModification.appIconFileId == null}"
th:src="'data:' + ${apiKeyModification.appIconContentType} + ';base64,' + ${T(java.util.Base64).getEncoder().encodeToString(apiKeyModification.appIconData)}"
alt="미리보기" style="display: block;">
<!-- JS용 이미지 타겟 -->
<img id="iconPreviewImageJS" class="s1-preview-img" style="display: none;" alt="미리보기">
<div id="iconPlaceholder" class="s1-upload-inner" th:style="${(apiKeyModification.appIconData != null or apiKeyModification.appIconFileId != null) ? 'display: none;' : 'display: flex;'}">
<img th:src="@{/img/icon/img_icon.png}">
<p class="s1-upload-title">앱 아이콘 이미지 파일을 업로드 하세요</p>
<p class="s1-upload-hint">권장 사이즈는 1280 * 12 픽셀이며 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"
th:style="${(apiKeyModification.appIconData != null or apiKeyModification.appIconFileId != null) ? 'display: flex;' : '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>
<!-- 앱 이름 -->
<div class="s1-field">
<label class="s1-label">앱 이름 <span class="s1-required">*</span></label>
@@ -168,6 +132,42 @@
<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">
<!-- 기존 파일 ID로 이미지 노출 -->
<img class="s1-preview-img" id="iconPreviewImage1"
th:if="${apiKeyModification.appIconFileId != null}"
th:src="@{/file/download(fileSn=1,fileId=${apiKeyModification.appIconFileId})}"
alt="미리보기" style="display: block;">
<!-- 데이터에서 이미지 노출 -->
<img class="s1-preview-img" id="iconPreviewImage2"
th:if="${apiKeyModification.appIconData != null and apiKeyModification.appIconFileId == null}"
th:src="'data:' + ${apiKeyModification.appIconContentType} + ';base64,' + ${T(java.util.Base64).getEncoder().encodeToString(apiKeyModification.appIconData)}"
alt="미리보기" style="display: block;">
<!-- JS용 이미지 타겟 -->
<img id="iconPreviewImageJS" class="s1-preview-img" style="display: none;" alt="미리보기">
<div id="iconPlaceholder" class="s1-upload-inner" th:style="${(apiKeyModification.appIconData != null or apiKeyModification.appIconFileId != null) ? 'display: none;' : 'display: flex;'}">
<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"
th:style="${(apiKeyModification.appIconData != null or apiKeyModification.appIconFileId != null) ? 'display: flex;' : '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>
@@ -204,7 +204,8 @@
removeBtn.style.display = 'none';
}
function addIpAddress() {
function addIpAddress(refocus) {
if (refocus === undefined) refocus = true;
const ipInput = document.getElementById('ipWhitelistInput');
const ipValue = ipInput.value.trim();
@@ -227,7 +228,7 @@
ipAddresses.push(ipValue);
renderIpList();
ipInput.value = '';
ipInput.focus();
if (refocus) ipInput.focus();
}
function removeIpAddress(ip) {
@@ -342,6 +343,12 @@
}
// IP input Enter key
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 경로: 재포커스 안 함
});
ipInput.addEventListener('keypress', function (e) {
if (e.key === 'Enter') { e.preventDefault(); addIpAddress(); }
});
@@ -23,7 +23,7 @@
<div class="step2-wrap">
<!-- Title -->
<h2 class="s2-title"> 수정</h2>
<h2 class="s2-title">클라이언트 정보 - 수정</h2>
<!-- Progress Steps Card -->
<div class="s1-progress-card">
@@ -86,7 +86,7 @@
</svg>
</div>
<span class="s1-step-num">3단계</span>
<span class="s1-step-name">앱 수정 완료</span>
<span class="s1-step-name">클라이언트 신청 완료</span>
</div>
</div>
</div>
@@ -24,7 +24,7 @@
<div class="step3-wrap">
<!-- Title -->
<h2 class="s3-title"> 수정</h2>
<h2 class="s3-title">클라이언트 정보 - 수정</h2>
<!-- Progress Steps Card -->
<div class="s1-progress-card">
@@ -87,7 +87,7 @@
</svg>
</div>
<span class="s1-step-num">3단계</span>
<span class="s1-step-name">앱 수정 완료</span>
<span class="s1-step-name">클라이언트 신청 완료</span>
</div>
</div>
</div>
@@ -151,7 +151,7 @@
</div>
<div class="s3-message-wrapper">
<h1 class="s3-success-title">앱 수정 신청이 완료되었습니다.</h1>
<h1 class="s3-success-title">클라이언트 변경 신청이 완료되었습니다.</h1>
<p class="s3-success-desc">
<span class="s3-highlight">담당자 승인 후 변경 사항이 적용됩니다.</span>
<br>
@@ -24,7 +24,7 @@
<div class="step1-wrap">
<!-- Title -->
<h2 class="s1-title">앱생성</h2>
<h2 class="s1-title">클라이언트 정보 - 신규</h2>
<!-- Progress Steps Card -->
<div class="s1-progress-card">
@@ -84,7 +84,7 @@
</svg>
</div>
<span class="s1-step-num">3단계</span>
<span class="s1-step-name">앱 생성 완료</span>
<span class="s1-step-name">클라이언트 신청 완료</span>
</div>
</div>
@@ -100,27 +100,6 @@
<form id="registerStep1Form" method="post" th:action="@{/myapikey/register/step1}"
th:object="${apiKeyRegistration}" enctype="multipart/form-data">
<!-- 앱 아이콘 -->
<div class="s1-field">
<label class="s1-label">앱 아이콘</label>
<div class="s1-upload-box" 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">권장 사이즈는 1280 * 12 픽셀이며 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>
<!-- 앱 이름 -->
<div class="s1-field">
<label class="s1-label">앱 이름 <span class="s1-required">*</span></label>
@@ -171,6 +150,27 @@
<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>
@@ -202,7 +202,8 @@
removeBtn.style.display = 'none';
}
function addIpAddress() {
function addIpAddress(refocus) {
if (refocus === undefined) refocus = true;
const ipInput = document.getElementById('ipWhitelistInput');
const ipValue = ipInput.value.trim();
@@ -225,7 +226,7 @@
ipAddresses.push(ipValue);
renderIpList();
ipInput.value = '';
ipInput.focus();
if (refocus) ipInput.focus();
}
function removeIpAddress(ip) {
@@ -360,6 +361,15 @@
if (e.key === 'Enter') { e.preventDefault(); addIpAddress(); }
});
// 포커스 아웃 시 자동 추가/검증 (사용자가 "추가" 버튼을 지나치는 경우 대응).
// "추가" 버튼으로 포커스 이동 시엔 버튼 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();
@@ -23,7 +23,7 @@
<div class="step2-wrap">
<!-- Title -->
<h2 class="s2-title">앱생성</h2>
<h2 class="s2-title">클라이언트 정보 - 신규</h2>
<!-- Progress Steps Card -->
<div class="s1-progress-card">
@@ -86,7 +86,7 @@
</svg>
</div>
<span class="s1-step-num">3단계</span>
<span class="s1-step-name">앱 생성 완료</span>
<span class="s1-step-name">클라이언트 신청 완료</span>
</div>
</div>
</div>
@@ -24,7 +24,7 @@
<div class="step3-wrap">
<!-- Title -->
<h2 class="s3-title">앱생성</h2>
<h2 class="s3-title">클라이언트 정보 - 신규</h2>
<!-- Progress Steps Card -->
<div class="s1-progress-card">
@@ -87,7 +87,7 @@
</svg>
</div>
<span class="s1-step-num">3단계</span>
<span class="s1-step-name">앱 생성 완료</span>
<span class="s1-step-name">클라이언트 신청 완료</span>
</div>
</div>
</div>
@@ -151,7 +151,7 @@
</div>
<div class="s3-message-wrapper">
<h1 class="s3-success-title">앱 생성이 완료되었습니다.</h1>
<h1 class="s3-success-title">클라이언트 신청이 완료되었습니다.</h1>
<p class="s3-success-desc">
담당자 앱 승인 후 <span class="s3-highlight">[앱 정보]</span> 화면에서
<br>
@@ -190,7 +190,7 @@
<div class="dt-actions" style="justify-content: flex-end; gap: 11px; margin-top: 30px;">
<a th:href="@{/myapikey}" 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)">인증키 삭제</button>
th:data-client-id="${apiKey.clientid}" onclick="deleteApiKeyFromButton(this)">API 이용 해지</button>
<a sec:authorize="hasRole('ROLE_API_KEY_REQUEST')" class="dt-btn-blue"
th:href="@{/myapikey/modify/step1(clientId=${apiKey.clientid})}">수정</a>
</div>
@@ -206,6 +206,15 @@
</div>
</div>
<!-- 회원 구분별 이용 가능 서비스 (TBD) -->
<section class="signup-roles-tbd">
<h2 class="signup-roles-tbd__title">회원 구분별 이용 가능 서비스</h2>
<div class="signup-roles-tbd__placeholder">
<span class="signup-roles-tbd__badge">TBD</span>
<p class="signup-roles-tbd__desc">회원 구분별 이용 가능 서비스 안내가 준비 중입니다.</p>
</div>
</section>
<div class="signup-action">
<a th:href="@{/signup}" class="btn-action-primary">회원가입하러 가기</a>
</div>
@@ -22,6 +22,9 @@
<span th:if="${statsAggregationMinute != null}"
th:text="'통계 데이터는 매시 ' + ${statsAggregationMinute} + '분 집계되며, 집계 이전 시간 기준으로 생성됩니다.'">통계 안내</span>
<span th:if="${statsAggregationMinute == null}">통계 데이터는 매시 집계되며, 집계 이전 시간 기준으로 생성됩니다.</span>
<span class="statistics-notice-example" th:if="${statsAggregationMinute != null}"
th:text="'예) 매시 ' + ${statsAggregationMinute} + '분 집계 기준 — 현재 12:30이면 12:00 이전, 12:05이면 11:00 이전에 발생한 거래가 대상입니다.'">예시</span>
<span class="statistics-notice-example" th:if="${statsAggregationMinute == null}">예) 현재 12:30이면 12:00 이전, 12:05이면 11:00 이전에 발생한 거래가 대상입니다.</span>
<span th:if="${statsLatestTime != null}" class="statistics-notice-latest"
th:text="'※ 최신 집계 시각: ' + ${statsLatestTime} + ' 기준'">최신 집계 시각</span>
</div>
@@ -82,7 +82,7 @@
<a th:href="@{/users}"><i class="fas fa-users"></i>개발자 관리</a>
</li>
<li sec:authorize="hasRole('ROLE_APP')">
<a th:href="@{/myapikey}"><i class="fas fa-key"></i> 관리</a>
<a th:href="@{/myapikey}"><i class="fas fa-key"></i>API 신청 관리</a>
<a th:href="@{/webhook}" sec:authorize="hasRole('ROLE_WEBHOOK')"><i class="fas fa-bell"></i>Webhook 관리</a>
<a th:href="@{/statistics/api}"><i class="fas fa-chart-bar"></i>이용 통계</a>
</li>
@@ -239,7 +239,7 @@
</button>
<ul class="drawer-submenu">
<li sec:authorize="hasRole('ROLE_CORP_MANAGER')"><a th:href="@{/users}">개발자 관리</a></li>
<li sec:authorize="hasRole('ROLE_APP')"><a th:href="@{/myapikey}"> 관리</a></li>
<li sec:authorize="hasRole('ROLE_APP')"><a th:href="@{/myapikey}">API 신청 관리</a></li>
<li sec:authorize="hasRole('ROLE_WEBHOOK')"><a th:href="@{/webhook}">Webhook 관리</a></li>
<li sec:authorize="hasRole('ROLE_APP')"><a th:href="@{/statistics/api}">이용 통계</a></li>
<li><a th:href="@{/mypage}">내 정보 관리</a></li>
@@ -57,7 +57,7 @@
<a th:href="@{/myapikey}"
th:classappend="${activeMenu == 'apiKey'} ? 'service-nav__item--active' : ''"
class="service-nav__item"> 관리</a>
class="service-nav__item">API 신청 관리</a>
<a th:href="@{/webhook}"
th:classappend="${activeMenu == 'webhook'} ? 'service-nav__item--active' : ''"
@@ -26,5 +26,6 @@
<section layout:fragment="pagePopups"></section>
<script th:src="@{/js/popup/custom-popups.js}"></script>
<script th:src="@{/js/popup/two-factor-auth.js}"></script>
<script th:src="@{/js/djb/toast.js}"></script>
</body>
</html>
@@ -27,5 +27,6 @@
<section layout:fragment="pagePopups"></section>
<script th:src="@{/js/popup/custom-popups.js}"></script>
<script th:src="@{/js/popup/two-factor-auth.js}"></script>
<script th:src="@{/js/djb/toast.js}"></script>
</body>
</html>