클라이언트 생성/수정 흐름 및 관련 UI 개선:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled

- 클라이언트 등록/수정 URL 구조 리팩토링 (myapikey → clients)
- 로그인 직후 클라이언트 미보유 사용자 대상 추가 안내 팝업
- 클라이언트 Secret 조회 및 관련 2FA 단계 로직 개선
This commit is contained in:
Rinjae
2026-07-29 19:25:10 +09:00
parent c02a5ff047
commit 02eb81af67
37 changed files with 535 additions and 197 deletions
+15 -15
View File
@@ -367,37 +367,37 @@ page:
path: "/users/detail"
apikey:
name: "API 신청 관리"
path: "/myapikey"
path: "/clients"
app_request_detail:
name: "인증키 신청 상세"
path: "/myapikey/app_request_detail"
path: "/clients/app_request_detail"
credential_detail:
name: "인증키 정보"
path: "/myapikey/credential_detail"
path: "/clients/credential_detail"
password_verify:
name: "비밀번호 변경"
path: "/password/verify"
password_change:
name: "비밀번호 변경"
path: "/password/change"
myapikey_register_step1:
clients_register_step1:
name: "앱 생성 (기본 정보)"
path: "/myapikey/register/step1"
myapikey_register_step2:
path: "/clients/register/step1"
clients_register_step2:
name: "앱 생성 (API 선택)"
path: "/myapikey/register/step2"
myapikey_register_step3:
path: "/clients/register/step2"
clients_register_step3:
name: "앱 생성 요청 완료"
path: "/myapikey/register/step3"
myapikey_modify_step1:
path: "/clients/register/step3"
clients_modify_step1:
name: "앱 수정 (기본 정보)"
path: "/myapikey/modify/step1"
myapikey_modify_step2:
path: "/clients/modify/step1"
clients_modify_step2:
name: "앱 수정 (API 선택)"
path: "/myapikey/modify/step2"
myapikey_modify_step3:
path: "/clients/modify/step2"
clients_modify_step3:
name: "앱 수정 요청 완료"
path: "/myapikey/modify/step3"
path: "/clients/modify/step3"
api_statistics:
name: "이용 통계"
path: "/statistics/api"
+3 -2
View File
@@ -7764,9 +7764,8 @@ button.djb-comment-submit:disabled {
.tfa-code-row .tfa-code-input-wrap {
flex: 1;
display: flex;
align-items: center;
align-items: stretch;
height: 56px;
padding: 0 14px;
border: 1px solid #BDC7CF;
border-radius: 4px;
background: #fff;
@@ -7778,6 +7777,8 @@ button.djb-comment-submit:disabled {
.tfa-code-row .tfa-code-input {
flex: 1;
min-width: 0;
height: 100%;
padding: 0 14px;
border: 0;
outline: none;
background: transparent;
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -449,7 +449,7 @@ document.addEventListener('DOMContentLoaded', function() {
// Form submit
// - Webhook/앱 변경은 API 가 필수(form data-api-required=true)이므로 미선택 시 차단한다.
// - 앱 신청(myapikey register)은 선택 사항이라, 미선택 시 확인 팝업으로 한 번 더 묻고 진행한다.
// - 앱 신청(clients register)은 선택 사항이라, 미선택 시 확인 팝업으로 한 번 더 묻고 진행한다.
form.addEventListener('submit', function(e) {
if (selectedApis.size !== 0) {
syncHiddenSelected();
+3 -1
View File
@@ -5,6 +5,7 @@
* 추가로, 페이지 진입 시 URL 쿼리 `apiApplyToast` 값에 따라 안내 토스트를 자동 노출한다.
* - new : 신규 클라이언트 생성 페이지 진입 안내
* - existing : 클라이언트 목록(선택) 진입 안내
* - modify : 클라이언트 1건 보유 → 수정 플로우 API 선택 직행 안내
* 토스트 노출 후 쿼리를 정리(replaceState)해 새로고침 시 재노출을 막는다.
*/
(function (global) {
@@ -54,7 +55,8 @@
// ── URL 쿼리 기반 자동 안내 토스트 ──
var API_APPLY_MESSAGES = {
"new": "API 를 사용할 클라이언트 생성을 먼저 진행해주세요.",
"existing": "API 를 추가 사용할 클라이언트를 선택해주세요"
"existing": "API 를 추가 사용할 클라이언트를 선택해주세요",
"modify": "보유 클라이언트에 추가 사용할 API 를 선택해주세요"
};
function handleApiApplyToast() {
@@ -31,6 +31,7 @@ var LoginSuccessHandler = (function() {
pendingInvitation: false,
pendingInvitationToken: '',
pendingInvitationOrgName: '',
needClientRegister: false,
sessionSuccessMsg: '',
redirectUrl: ''
};
@@ -117,6 +118,19 @@ var LoginSuccessHandler = (function() {
}
}
);
return; // 다른 체크 중단
}
// 6. 클라이언트(앱/API키) 미보유 법인 사용자 → 신규 신청 유도 (로그인 직후 1회)
if (config.needClientRegister) {
customPopups.showConfirm(
'API 사용을 위해서는 클라이언트 신규 신청이 필요합니다.<br>지금 신청하시겠습니까?',
function(selection) {
if (selection) {
window.location.href = '/clients/register/step1';
}
}
);
}
}
@@ -133,6 +133,7 @@ const customPopups = {
// 기본값 설정
const title = options.title || '비밀번호 입력';
const message = options.message || '계속하려면 비밀번호를 입력해주세요.';
const placeholder = options.placeholder || '비밀번호를 입력하세요';
const onConfirm = options.onConfirm;
const onCancel = options.onCancel;
@@ -140,8 +141,8 @@ const customPopups = {
$('#passwordPopupTitle').text(title);
$('#passwordPopupMessage').html(customPopups._sanitizeHtml(message));
// 입력 필드 및 에러 초기화
$('#passwordPopupInput').val('').removeClass('error');
// 입력 필드 및 에러 초기화 (placeholder 는 호출부 커스텀 허용)
$('#passwordPopupInput').val('').removeClass('error').attr('placeholder', placeholder);
$('#passwordPopupError').removeClass('show').text('');
// 팝업 표시 (modal 구조 사용)
@@ -3,7 +3,7 @@
*
* TwoFactorAuth.open({
* mode: 'login' | 'stepup', // 참고용(서버가 세션으로 판별). 로깅/분기용
* purpose: '/myapikey/...', // step-up 대상 보호 경로(로그인은 생략)
* purpose: '/clients/...', // step-up 대상 보호 경로(로그인은 생략)
* onSuccess: function(res){}, // 검증 성공. login 이면 res.redirect 사용
* onCancel: function(reason){}// 닫기/타임아웃/실패로 종료
* });
@@ -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: 'Reset (응답지우기)' },
'op.reset': { en: 'Reset', ko: '초기화' },
'op.parameters': { en: 'Parameters', ko: '파라미터' },
'op.parameter': { en: 'Parameter', ko: '파라미터' },
@@ -203,9 +203,8 @@ $tfa-danger: #F4253C;
.tfa-code-input-wrap {
flex: 1;
display: flex;
align-items: center;
align-items: stretch;
height: 56px;
padding: 0 14px;
border: 1px solid $tfa-border-2;
border-radius: 4px;
background: #fff;
@@ -217,6 +216,8 @@ $tfa-danger: #F4253C;
.tfa-code-input {
flex: 1;
min-width: 0;
height: 100%;
padding: 0 14px;
border: 0;
outline: none;
background: transparent;
@@ -281,6 +281,8 @@
// 활성 탭은 서버가 결정하고, 테스트베드 탭이 활성이면서 인증된 경우에만
// Swagger UI를 초기화한다(실제 초기화는 하단에서 실행 — const 선언 이후).
const currentApiId = /*[[${apiSpecInfo.apiId}]]*/ 'default';
// 현재 API 의 응답유형(sample/mock/gw) — mock 이면 샘플 Secret + mock 토큰으로 인증 진행
const CURRENT_RESPONSE_TYPE = /*[[${apiSpecInfo.responseType}]]*/ 'sample';
const activeTab = /*[[${activeTab}]]*/ 'api-info';
const isAuthenticated = /*[[${authenticated}]]*/ false;
const testbedActive = (activeTab === 'testbed' && isAuthenticated);
@@ -421,7 +423,7 @@
// ===== API 사용 신청: 회원 구분별 분기 (API 신청 절차) =====
// 비회원/개인회원 → 법인회원 안내 팝업 → 회원가입 안내 이동
// 법인이용자 → 클라이언트 현황: 신규(0건)→신규 클라이언트 / 기존(앱 있음)→클라이언트 관리
// 법인이용자 → 클라이언트 현황: 신규(0건)→신규 클라이언트 / 기존(1건)→클라이언트 수정의 API 선택 직행 / 기존(1건 초과)→클라이언트 관리
function requestApiUse() {
fetch(/*[[@{/djb/testbed/auth/context}]]*/ '/djb/testbed/auth/context')
.then(function (r) { return r.json(); })
@@ -438,16 +440,24 @@
} 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';
// 법인이용자: 보유 클라이언트 로 신규/기존 분기 (이동 페이지에서 Toast 안내)
const creds = (ctx && ctx.credentials) || [];
if (creds.length === 1) {
// 기존 1건 → 해당 클라이언트 수정 플로우의 API 선택(2단계) 직행 + Toast
const modifyBase = /*[[@{/clients/modify/step1}]]*/ '/clients/modify/step1';
window.location.href = modifyBase
+ '?clientId=' + encodeURIComponent(creds[0].clientId)
+ '&goto=apis&apiApplyToast=modify';
return;
}
if (creds.length > 1) {
// 기존 여러 건 → 클라이언트 목록(선택) + Toast
window.location.href = /*[[@{/clients(apiApplyToast='existing')}]]*/ '/clients?apiApplyToast=existing';
return;
}
// 신규(클라이언트 0) → 생성 필요 안내 확인 후 이동
const goNew = function () {
window.location.href = /*[[@{/myapikey/register/step1(clear=true,apiApplyToast='new')}]]*/ '/myapikey/register/step1?clear=true&apiApplyToast=new';
window.location.href = /*[[@{/clients/register/step1(clear=true,apiApplyToast='new')}]]*/ '/clients/register/step1?clear=true&apiApplyToast=new';
};
if (typeof customPopups !== 'undefined' && customPopups.showConfirm) {
customPopups.showConfirm(
@@ -458,7 +468,7 @@
})
.catch(function (e) {
console.error('API 사용 신청 컨텍스트 조회 실패', e);
window.location.href = /*[[@{/myapikey}]]*/ '/myapikey';
window.location.href = /*[[@{/clients}]]*/ '/clients';
});
}
Array.prototype.forEach.call(document.querySelectorAll('.api-apply-btn'), function (btn) {
@@ -533,33 +543,119 @@
.catch(function (e) { console.error('테스트베드 컨텍스트 로드 실패', e); });
}
function fetchOAuthToken(clientId, clientSecret, gw) {
// 인증 실패/취소 시 앱 선택을 미선택 상태로 되돌린다 (mock 포함 — 선택 유지 시 인증된 것처럼 보이는 오해 방지)
function resetAppSelection() {
if (appsSelect) appsSelect.value = '';
}
// 테스트베드 오류 안내 — modal dialog (custom-popups)
function showTestbedError(msg) {
if (typeof customPopups !== 'undefined' && customPopups.showAlert) {
customPopups.showAlert(msg);
} else {
alert(String(msg).replace(/<br\s*\/?>/gi, '\n'));
}
}
function fetchOAuthToken(clientId, clientSecret, tokenUrl) {
const body = 'grant_type=client_credentials'
+ '&client_id=' + encodeURIComponent(clientId)
+ '&client_secret=' + encodeURIComponent(clientSecret)
+ '&scope=api';
// 토큰 발급 프록시 여부(djb.gateway.token-use-proxy). false 면 토큰 URL 로 브라우저 직접 호출.
var direct = (window.__djbTokenUseProxy === false);
var url = direct ? gw.tokenUrl : window.location.origin + (/*[[@{/api/call-api}]]*/ '/api/call-api');
var url = direct ? tokenUrl : window.location.origin + (/*[[@{/api/call-api}]]*/ '/api/call-api');
var headers = { 'Content-Type': 'application/x-www-form-urlencoded' };
if (!direct) {
headers['original-url'] = gw.tokenUrl;
headers['original-url'] = tokenUrl;
headers['X-XSRF-TOKEN'] = /*[[${_csrf.token}]]*/ '';
}
return fetch(url, {
method: 'POST',
headers: headers,
body: body
}).then(function (r) { return r.ok ? r.json() : null; })
.then(function (data) { return data ? data.access_token : null; });
}).then(function (r) {
// 실패 시에도 프록시(ApiTesterFilter)가 {error, detail} JSON 을 반환 — 메시지로 살려 UI 에 표시
return r.json().catch(function () { return null; }).then(function (data) {
if (!r.ok || !data || !data.access_token) {
var msg = (data && (data.error || data.error_description))
|| ('토큰 발급 요청 실패 (HTTP ' + r.status + ')');
var detail = (data && data.detail) ? '<br><span style="color:#888;font-size:13px;">' + data.detail + '</span>' : '';
throw new Error('인증 토큰 발급에 실패했습니다.<br>' + msg + detail);
}
return data.access_token;
});
});
}
// 현재 API 가 mock 응답유형인지 (전역 게이트웨이 설정과 무관하게 API 별 판단)
function isMockApi() {
return (CURRENT_RESPONSE_TYPE || '').toLowerCase() === 'mock';
}
function authorizeApp(secret) {
const gw = window.__djbGateway || {};
if (!window.ui) return;
// 응답유형 mock: 실 Secret 검증 없이 샘플 SecretKey + mock 토큰으로 인증 진행 — toast 안내
if (isMockApi()) {
if (typeof djbToast === 'function') {
djbToast('Mock 서버로 호출되므로 별도 Secret Key 로 인증 절차를 진행합니다.', { type: 'info', duration: 5000 });
}
proceedAuthorize(secret, secret.clientSecret || 'mock-secret', gw);
return;
}
// gw 응답유형 + Client Secret 이미 1회 노출·삭제 → 사용자가 보관 중인 Secret 직접 입력
if (!secret.clientSecret) {
promptClientSecret(secret, gw);
return;
}
proceedAuthorize(secret, secret.clientSecret, gw);
}
// Client Secret 수동 입력 팝업 (포탈에는 이미 삭제된 경우)
function promptClientSecret(secret, gw) {
if (typeof customPopups === 'undefined' || !customPopups.showPasswordInput) {
resetAppSelection();
showTestbedError('선택한 앱의 Client Secret 이 포탈에 존재하지 않습니다.<br>인증키를 새로 신청해 주세요.');
return;
}
customPopups.showPasswordInput({
title: 'Client Secret 입력',
message: 'Client Secret 은 1회 노출 후 삭제되어 포탈에 없습니다.<br>'
+ '보관 중인 값을 입력해 주세요.<br><br>'
+ '입력한 값은 저장되지 않고 인증에만 사용됩니다.',
placeholder: 'Client Secret 을 입력하세요',
onConfirm: function (value) {
customPopups.hidePasswordInput();
proceedAuthorize(secret, value, gw);
},
onCancel: function () {
// 취소 — 인증 미완료이므로 앱 선택도 되돌린다
resetAppSelection();
}
});
}
// authorize 반영 직후, 이미 열려 있는 "요청 스니펫" 상시 패널을 재렌더해 인증 헤더를 즉시 반영.
// (패널은 탭 클릭/Execute/입력 변경에만 자체 갱신하므로 앱 선택 시점엔 명시 호출이 필요)
function refreshSnippetPanel() {
if (window.DjbSwaggerSnippetPanel && typeof DjbSwaggerSnippetPanel.render === 'function') {
// authorize 상태가 Swagger store 에 반영된 뒤 읽도록 한 틱 늦춘다
setTimeout(function () { DjbSwaggerSnippetPanel.render(); }, 50);
}
}
// 확보된 Client Secret 으로 인증 주입 (OAUTH: 토큰 발급 / API_KEY: 헤더 주입)
// 토큰 URL: mock API → 포탈 mock 토큰 경로(ApiTesterFilter 가 즉시 발급), gw API → 실 GW 토큰 엔드포인트
function proceedAuthorize(secret, clientSecret, gw) {
var tokenUrl = isMockApi()
? window.location.origin + '/api/v1/oauth/token'
: gw.tokenUrl;
if (secret.authType === 'OAUTH') {
fetchOAuthToken(secret.clientId, secret.clientSecret, gw).then(function (token) {
if (!token) return;
fetchOAuthToken(secret.clientId, clientSecret, tokenUrl).then(function (token) {
window.ui.authActions.authorize({
djbOAuth: {
name: 'djbOAuth',
@@ -567,15 +663,21 @@
value: 'Bearer ' + token
}
});
refreshSnippetPanel();
}).catch(function (e) {
console.error('토큰 발급 실패', e);
resetAppSelection();
showTestbedError(e && e.message ? e.message : '인증 토큰 발급 중 오류가 발생했습니다.');
});
} else if (secret.authType === 'API_KEY') {
window.ui.authActions.authorize({
djbApiKey: {
name: 'djbApiKey',
schema: { type: 'apiKey', in: 'header', name: gw.apiKeyHeader },
value: secret.clientSecret
value: clientSecret
}
});
refreshSnippetPanel();
}
}
@@ -584,9 +686,18 @@
if (!currentApiId || currentApiId === 'default' || currentApiId === DEFAULT_TOKEN_API_ID) return;
fetch((/*[[@{/djb/testbed/auth/credentials/}]]*/ '/djb/testbed/auth/credentials/')
+ encodeURIComponent(clientId) + '/secret?apiId=' + encodeURIComponent(currentApiId))
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (r) {
if (!r.ok) {
throw new Error('앱 인증정보 조회에 실패했습니다. (HTTP ' + r.status + ')');
}
return r.json();
})
.then(function (secret) { if (secret) authorizeApp(secret); })
.catch(function (e) { console.error('앱 인증정보 주입 실패', e); });
.catch(function (e) {
console.error('앱 인증정보 주입 실패', e);
resetAppSelection();
showTestbedError(e && e.message ? e.message : '앱 인증정보 주입 중 오류가 발생했습니다.');
});
}
// 테스트베드 탭 활성 + 인증 상태일 때만 Swagger UI/앱 인증 컨텍스트 초기화
@@ -328,7 +328,7 @@
</p>
<div class="action-buttons">
<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>
<a th:href="@{/partnership}" class="action-btn btn-primary">피드백 / 개선요청 <i class="bi bi-patch-question"></i></a>
</div>
</div>
<div class="info-image-box">
@@ -607,6 +607,7 @@
pendingInvitation: [[${ session.pendingInvitation }]],
pendingInvitationToken: [[${ session.pendingInvitationToken }]],
pendingInvitationOrgName: [[${ session.pendingInvitationOrgName }]],
needClientRegister: [[${ needClientRegister }]],
sessionSuccessMsg: [[${ session.success }]],
redirectUrl: [[${ session.redirectUrl }]]
});
@@ -35,7 +35,7 @@
<!-- App Requests (Pending) -->
<th:block th:if="${appRequests != null and !appRequests.isEmpty()}">
<a class="app-card-figma" th:each="request : ${appRequests}"
th:href="@{/myapikey/app_request_detail(id=${request.id})}">
th:href="@{/clients/app_request_detail(id=${request.id})}">
<!-- App Icon -->
<div class="app-card-icon-box">
@@ -85,7 +85,7 @@
<!-- API Keys (Approved/Inactive) -->
<th:block th:if="${apiKeys != null and !apiKeys.isEmpty()}">
<a class="app-card-figma" th:each="apikey : ${apiKeys}"
th:href="@{/myapikey/credential_detail(id=${apikey.clientid})}">
th:href="@{/clients/credential_detail(id=${apikey.clientid})}">
<!-- App Icon -->
<div class="app-card-icon-box">
@@ -156,7 +156,7 @@
keysToRemove.forEach(key => sessionStorage.removeItem(key));
// Redirect to the API key registration wizard with clear parameter
window.location.href = '/myapikey/register/step1?clear=true';
window.location.href = '/clients/register/step1?clear=true';
}
if (requestApiKeyBtn) {
@@ -22,12 +22,12 @@
<div class="step1-wrap">
<!-- Title -->
<h2 class="s1-title">클라이언트 정보 - 수정</h2>
<h2 class="s1-title">클라이언트 정보 - 변경</h2>
<!-- Progress Steps Card -->
<div class="s1-progress-card">
<div class="s1-steps">
<!-- Step 1 Active: 정보수정 (form/input icon) -->
<!-- Step 1 Active: 클라이언트 정보수정 (form/input 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"
@@ -39,7 +39,7 @@
</svg>
</div>
<span class="s1-step-num">1단계</span>
<span class="s1-step-name"> 정보수정</span>
<span class="s1-step-name">클라이언트 정보수정</span>
</div>
<div class="s1-step-line"></div>
@@ -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>
@@ -84,7 +84,7 @@
<!-- Form Card -->
<div class="s1-form-card">
<form id="modifyStep1Form" method="post" th:action="@{/myapikey/modify/step1}"
<form id="modifyStep1Form" method="post" th:action="@{/clients/modify/step1}"
th:object="${apiKeyModification}" enctype="multipart/form-data">
<!-- Hidden clientId -->
@@ -173,7 +173,7 @@
<!-- 다음 버튼 -->
<div class="s1-actions" style="justify-content: flex-end; gap: 10px;">
<a th:href="@{/myapikey/modify/cancel(clientId=${apiKeyModification.clientId})}" class="s1-btn-next" style="background: #bdc7cf; flex: none; width: 120px; text-decoration: none; display: flex; justify-content: center; align-items: center;">취소</a>
<a th:href="@{/clients/modify/cancel(clientId=${apiKeyModification.clientId})}" class="s1-btn-next" style="background: #bdc7cf; flex: none; width: 120px; text-decoration: none; display: flex; justify-content: center; align-items: center;">취소</a>
<button type="submit" form="modifyStep1Form" class="s1-btn-next" style="flex: none; width: 120px;">다음</button>
</div>
@@ -349,8 +349,12 @@
if (!ipInput.value.trim()) return; // 빈 값은 무시
addIpAddress(false); // blur 경로: 재포커스 안 함
});
ipInput.addEventListener('keypress', function (e) {
if (e.key === 'Enter') { e.preventDefault(); addIpAddress(); }
// Enter/Tab 모두 입력값 등록. 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); }
});
// Form submit validation
@@ -23,12 +23,12 @@
<div class="step2-wrap">
<!-- Title -->
<h2 class="s2-title">클라이언트 정보 - 수정</h2>
<h2 class="s2-title">클라이언트 정보 - 변경</h2>
<!-- Progress Steps Card -->
<div class="s1-progress-card">
<div class="s1-steps">
<!-- Step 1: 정보수정 (completed) -->
<!-- Step 1: 클라이언트 정보수정 (completed) -->
<div class="s1-step">
<div class="s1-step-circle">
<!-- 입력폼 아이콘 -->
@@ -44,7 +44,7 @@
</svg>
</div>
<span class="s1-step-num">1단계</span>
<span class="s1-step-name"> 정보수정</span>
<span class="s1-step-name">클라이언트 정보수정</span>
</div>
<div class="s1-step-line"></div>
@@ -86,13 +86,13 @@
</svg>
</div>
<span class="s1-step-num">3단계</span>
<span class="s1-step-name">클라이언트 신청 완료</span>
<span class="s1-step-name">변경 신청 완료</span>
</div>
</div>
</div>
<!-- API 선택 공용 모듈 -->
<th:block th:replace="~{fragment/api_selector :: apiSelector(${apiServices}, ${apiKeyModification.selectedApis}, '/myapikey/modify/step2', '/myapikey/modify/step2/save')}"/>
<th:block th:replace="~{fragment/api_selector :: apiSelector(${apiServices}, ${apiKeyModification.selectedApis}, '/clients/modify/step2', '/clients/modify/step2/save')}"/>
<!-- clientId 는 모듈 폼에 form 속성으로 주입 -->
<input type="hidden" name="clientId" th:value="${apiKeyModification.clientId}" form="apiSelectorForm"/>
@@ -135,7 +135,7 @@
if (typeof TwoFactorAuth === 'undefined') { form.submit(); return; }
TwoFactorAuth.open({
mode: 'stepup',
purpose: '/myapikey/modify/step2',
purpose: '/clients/modify/step2',
// form.submit() 은 submit 이벤트를 재발생시키지 않으므로 그대로 서버로 전송된다.
onSuccess: function () { form.submit(); },
onCancel: function () { /* 사용자 취소 — step2 유지 */ }
@@ -24,12 +24,12 @@
<div class="step3-wrap">
<!-- Title -->
<h2 class="s3-title">클라이언트 정보 - 수정</h2>
<h2 class="s3-title">클라이언트 정보 - 변경</h2>
<!-- Progress Steps Card -->
<div class="s1-progress-card">
<div class="s1-steps">
<!-- Step 1: 정보수정 (form/input icon) -->
<!-- Step 1: 클라이언트 정보수정 (form/input icon) -->
<div class="s1-step">
<div class="s1-step-circle">
<!-- 입력폼 아이콘 -->
@@ -45,7 +45,7 @@
</svg>
</div>
<span class="s1-step-num">1단계</span>
<span class="s1-step-name"> 정보수정</span>
<span class="s1-step-name">클라이언트 정보수정</span>
</div>
<div class="s1-step-line"></div>
@@ -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>
@@ -161,7 +161,7 @@
<!-- Bottom Actions -->
<div class="s3-actions">
<a href="/myapikey" class="s3-btn-complete">
<a href="/clients" class="s3-btn-complete">
완료
</a>
</div>
@@ -179,10 +179,10 @@
</p>
</div>
<div class="s3-actions">
<a href="/myapikey" class="s3-btn-retry">
<a href="/clients" class="s3-btn-retry">
다시 시도
</a>
<a href="/myapikey" class="s3-btn-list">
<a href="/clients" class="s3-btn-list">
목록으로
</a>
</div>
@@ -97,7 +97,7 @@
<!-- Form Card -->
<div class="s1-form-card">
<form id="registerStep1Form" method="post" th:action="@{/myapikey/register/step1}"
<form id="registerStep1Form" method="post" th:action="@{/clients/register/step1}"
th:object="${apiKeyRegistration}" enctype="multipart/form-data">
<!-- 앱 이름 -->
@@ -356,9 +356,12 @@
});
}
// IP input Enter key
ipInput.addEventListener('keypress', function (e) {
if (e.key === 'Enter') { e.preventDefault(); addIpAddress(); }
// 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); }
});
// 포커스 아웃 시 자동 추가/검증 (사용자가 "추가" 버튼을 지나치는 경우 대응).
@@ -93,7 +93,7 @@
<!-- API 선택 공용 모듈 -->
<th:block
th:replace="~{fragment/api_selector :: apiSelector(${apiServices}, ${apiKeyRegistration.selectedApis}, '/myapikey/register/step2', '/myapikey/register/step2/save')}" />
th:replace="~{fragment/api_selector :: apiSelector(${apiServices}, ${apiKeyRegistration.selectedApis}, '/clients/register/step2', '/clients/register/step2/save')}" />
<!-- Bottom Navigation Actions -->
<div class="s2-actions">
@@ -161,7 +161,7 @@
<!-- Bottom Actions -->
<div class="s3-actions">
<a href="/myapikey" class="s3-btn-complete">
<a href="/clients" class="s3-btn-complete">
완료
</a>
</div>
@@ -179,10 +179,10 @@
</p>
</div>
<div class="s3-actions">
<a href="/myapikey/register/step1" class="s3-btn-retry">
<a href="/clients/register/step1" class="s3-btn-retry">
다시 시도
</a>
<a href="/myapikey" class="s3-btn-list">
<a href="/clients" class="s3-btn-list">
목록으로
</a>
</div>
@@ -158,13 +158,13 @@
</div>
<div class="btn_wrap bt_location" th:if="${appRequest.approval.approvalStatus.toString() == 'REQUESTED'}">
<form th:action="@{/myapikey/api_key_request/cancel}" method="post">
<form th:action="@{/clients/api_key_request/cancel}" method="post">
<input type="hidden" name="id" th:value="${appRequest.id}">
<button type="submit" class="btn_del">취소</button>
</form>
</div>
<div class="btn_inventory btn_mtop">
<a th:href="@{/myapikey/api_key_request/history}" class="common_btn_type_1">목록</a>
<a th:href="@{/clients/api_key_request/history}" class="common_btn_type_1">목록</a>
</div>
</div>
</div>
@@ -15,10 +15,10 @@
<div class="tabs">
<ul class="tab_nav tab_bt">
<li class="active">
<a th:href="@{/myapikey/api_key_request/history}">개발</a>
<a th:href="@{/clients/api_key_request/history}">개발</a>
</li>
<li>
<a th:href="@{/myapikey/api_key_request/prod_history}">운영</a>
<a th:href="@{/clients/api_key_request/prod_history}">운영</a>
</li>
</ul>
<div class="tab active">
@@ -49,7 +49,7 @@
<tbody>
<tr th:each="request, status : ${requests}">
<td th:text="${status.index + 1}">1</td>
<td><a th:href="@{/myapikey/api_key_request/detail(id=${request.id})}" class="btn btn-sm btn-primary"
<td><a th:href="@{/clients/api_key_request/detail(id=${request.id})}" class="btn btn-sm btn-primary"
th:text="'[' + ${request.clientName} + '] ' + ${request.type.description}"></a></td>
<td th:text="${request.approval.approvalStatus.description}">1</td>
<td th:text="${#temporals.format(request.approval.createdDate, 'yyyy-MM-dd')}"></td>
@@ -85,7 +85,7 @@
<tbody>
<tr th:each="request, status : ${requests}">
<td th:text="${status.index + 1}">1</td>
<td><a th:href="@{/myapikey/api_key_request/detail(id=${request.id})}" class="btn btn-sm btn-primary" th:text="${request.clientName}"></a></td>
<td><a th:href="@{/clients/api_key_request/detail(id=${request.id})}" class="btn btn-sm btn-primary" th:text="${request.clientName}"></a></td>
<td th:text="${request.approval.approvalStatus}">1</td>
<td th:text="${#temporals.format(request.approval.createdDate, 'yyyy-MM-dd')}"></td>
<td th:text="${#temporals.format(request.approval.approvalDate, 'yyyy-MM-dd')}"></td>
@@ -98,7 +98,7 @@
</div>
</div>
<div class="pagination" th:replace="~{fragment/pagination :: pagination(jsFunction='fn_select_page')}"></div>
<form name="listForm" th:action="@{/myapikey/api_key_request/history}" method="get">
<form name="listForm" th:action="@{/clients/api_key_request/history}" method="get">
<input type="hidden" id="page" name="page"/>
</form>
</div>
@@ -112,7 +112,7 @@
<script>
function fn_select_page(pageNo) {
document.listForm.page.value = pageNo;
document.listForm.action = '[[@{/myapikey/api_key_request/history}]]';
document.listForm.action = '[[@{/clients/api_key_request/history}]]';
document.listForm.submit();
}
</script>
@@ -231,7 +231,7 @@
신청 취소
</button>
<!-- List Button (gray) -->
<a th:href="@{/myapikey}" class="dt-btn-gray">
<a th:href="@{/clients}" class="dt-btn-gray">
목록
</a>
</div>
@@ -289,7 +289,7 @@
$('.loading-overlay').show();
$.ajax({
url: '/myapikey/api_key_request/cancel',
url: '/clients/api_key_request/cancel',
type: 'POST',
data: { id: requestId },
dataType: 'json',
@@ -299,7 +299,7 @@
}).done(function (response) {
if (response.success) {
alert(response.message || '신청이 취소되었습니다.');
window.location.href = /*[[@{/myapikey}]]*/ '/myapikey';
window.location.href = /*[[@{/clients}]]*/ '/clients';
} else {
alert(response.message || '신청 취소 중 오류가 발생했습니다.');
$('.loading-overlay').hide();
@@ -25,7 +25,7 @@
<div class="detail-wrap">
<!-- Title Bar -->
<div class="board-header">
<h2 class="board-title">인증키 정보</h2>
<h2 class="board-title">클라이언트 정보</h2>
<span class="board-desc">등록된 앱의 상세 정보를 확인할 수 있습니다.</span>
</div>
@@ -188,11 +188,11 @@
<!-- Bottom Navigation Actions -->
<div class="dt-actions" style="justify-content: flex-end; gap: 11px; margin-top: 30px;">
<a th:href="@{/myapikey}" class="dt-btn-gray">목록</a>
<a th:href="@{/clients}" class="dt-btn-gray">목록</a>
<button type="button" sec:authorize="hasRole('ROLE_API_KEY_REQUEST')" class="dt-btn-red"
th:data-client-id="${apiKey.clientid}" onclick="deleteApiKeyFromButton(this)">API 이용 해지</button>
<a sec:authorize="hasRole('ROLE_API_KEY_REQUEST')" class="dt-btn-blue"
th:href="@{/myapikey/modify/step1(clientId=${apiKey.clientid})}">수정</a>
th:href="@{/clients/modify/step1(clientId=${apiKey.clientid})}">변경 신청</a>
</div>
</div><!-- /detail-wrap -->
@@ -218,43 +218,48 @@
);
}
// Show password prompt for viewing client secret (최초 1회 노출 + 서버측 물리 삭제)
// Client Secret 조회 진입 — 확인 후 조회 (본인 확인은 step-up 2FA)
function showPasswordPrompt() {
customPopups.showPasswordInput({
title: 'Client Secret 조회',
message: '보안을 위해 비밀번호를 입력해주세요.<br>조회 즉시 값은 영구 삭제됩니다.',
onConfirm: function (password) {
$.ajax({
url: /*[[@{/myapikey/credential/reveal-secret}]]*/ '/myapikey/credential/reveal-secret',
type: 'POST',
contentType: 'application/json',
data: JSON.stringify({ clientId: CREDENTIAL_CLIENT_ID, password: password }),
headers: {
'X-XSRF-TOKEN': /*[[${_csrf.token}]]*/ 'token'
}
}).done(function (response) {
if (response.success) {
customPopups.hidePasswordInput();
// 서버가 반환한 secret을 화면에 1회 주입
$('#revealedSecretValue').text(response.secret);
$('#revealedSecretCopyBtn').attr('data-secret', response.secret).data('secret', response.secret);
$('#hiddenSecretBox').hide();
$('#revealedSecretBox').fadeIn(300);
} else if (response.alreadyRevealed) {
customPopups.hidePasswordInput();
showLostKeyGuide();
} else {
customPopups.showPasswordError(response.message || '비밀번호가 일치하지 않습니다.');
}
}).fail(function () {
customPopups.showPasswordError('오류가 발생했습니다. 다시 시도해주세요.');
});
},
onCancel: function () {
// User cancelled - do nothing
customPopups.showConfirm(
'Client Secret은 <strong>지금 한 번만</strong> 조회할 수 있으며,<br>조회 즉시 값은 <strong>영구 삭제</strong>됩니다.<br><br>조회하시겠습니까?',
function (confirmed) {
if (!confirmed) {
return;
}
doRevealSecret();
}
);
}
// Secret 조회 요청 (step-up 필요 시 2FA 후 동일 요청 재시도)
function doRevealSecret() {
$.ajax({
url: /*[[@{/clients/credential/reveal-secret}]]*/ '/clients/credential/reveal-secret',
type: 'POST',
contentType: 'application/json',
data: JSON.stringify({ clientId: CREDENTIAL_CLIENT_ID }),
headers: {
'X-XSRF-TOKEN': /*[[${_csrf.token}]]*/ 'token'
}
}).done(function (response) {
if (response.success) {
// 서버가 반환한 secret을 화면에 1회 주입
$('#revealedSecretValue').text(response.secret);
$('#revealedSecretCopyBtn').attr('data-secret', response.secret).data('secret', response.secret);
$('#hiddenSecretBox').hide();
$('#revealedSecretBox').fadeIn(300);
} else if (response.alreadyRevealed) {
showLostKeyGuide();
} else {
customPopups.showAlert(response.message || 'Client Secret 조회에 실패했습니다.');
}
}).fail(function (jqXHR) {
if (isStepUpRequired(jqXHR)) {
requireStepUp('/clients/credential/reveal-secret', function () { doRevealSecret(); });
return;
}
customPopups.showAlert('오류가 발생했습니다. 다시 시도해주세요.');
});
}
@@ -307,7 +312,7 @@
$('.loading-overlay').show();
$.ajax({
url: /*[[@{/myapikey/api_key_delete}]]*/ '/myapikey/api_key_delete',
url: /*[[@{/clients/api_key_delete}]]*/ '/clients/api_key_delete',
type: 'POST',
contentType: 'application/json',
data: JSON.stringify({ clientId: clientId }),
@@ -320,11 +325,11 @@
return;
}
customPopups.showAlert(response.msg || 'API Key가 삭제되었습니다.', function () {
window.location.href = /*[[@{/myapikey}]]*/ '/myapikey';
window.location.href = /*[[@{/clients}]]*/ '/clients';
});
}).fail(function (jqXHR, textStatus, errorThrown) {
if (isStepUpRequired(jqXHR)) {
requireStepUp('/myapikey/api_key_delete', function () { doDeleteApiKey(clientId); });
requireStepUp('/clients/api_key_delete', function () { doDeleteApiKey(clientId); });
return;
}
customPopups.showAlert('API 삭제 요청 중 오류가 발생했습니다: ' + errorThrown);
@@ -56,7 +56,7 @@
<div class="s2-selection-container">
<main class="s2-content-area">
<form id="apiSelectorForm" method="post" th:action="@{${formAction}}" class="s2-form"
th:attr="data-save-action=@{${saveAction}}, data-api-required=${!#strings.contains(formAction, '/myapikey/register')}">
th:attr="data-save-action=@{${saveAction}}, data-api-required=${!#strings.contains(formAction, '/clients/register')}">
<!-- Header filter: Search and Select All -->
<div class="s2-filter-header">
@@ -449,7 +449,7 @@
}
$('.loading-overlay').show();
$.ajax({
url: '/myapikey/api_key_request',
url: '/clients/api_key_request',
type: 'POST',
contentType: 'application/json',
data: JSON.stringify(requestData)
@@ -47,6 +47,7 @@
<li><a th:href="@{/partnership}">피드백/개선요청</a></li>
</ul>
</li>
<li><a href="#" class="nav-link">API Status</a></li>
</ul>
</nav>
@@ -82,7 +83,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>API 신청 관리</a>
<a th:href="@{/clients}"><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>
@@ -228,6 +229,10 @@
<li><a th:href="@{/partnership}">사업 제휴 문의</a></li>
</ul>
</li>
<!-- API Status -->
<li class="drawer-menu-item">
<a href="#" class="drawer-menu-btn">API Status</a>
</li>
<!-- 마이페이지 (Authenticated Only) -->
<li class="drawer-menu-item has-submenu" sec:authorize="isAuthenticated()">
@@ -239,7 +244,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}">API 신청 관리</a></li>
<li sec:authorize="hasRole('ROLE_APP')"><a th:href="@{/clients}">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>
@@ -55,7 +55,7 @@
class="service-nav__item"
sec:authorize="hasRole('ROLE_CORP_MANAGER')">개발자 관리</a>
<a th:href="@{/myapikey}"
<a th:href="@{/clients}"
th:classappend="${activeMenu == 'apiKey'} ? 'service-nav__item--active' : ''"
class="service-nav__item">API 신청 관리</a>
@@ -20,7 +20,7 @@
<!-- Modal Body -->
<div class="modal-body">
<p id="passwordPopupMessage" style="text-align: center; margin-bottom: 24px; color: #64748B;">
<p id="passwordPopupMessage" style="text-align: center; margin-bottom: 24px; color: #64748B; word-break: keep-all;">
계속하려면 비밀번호를 입력해주세요.
</p>