merge 충돌 해결
This commit is contained in:
@@ -316,6 +316,9 @@ page:
|
||||
api_detail:
|
||||
name: "API 상세보기"
|
||||
path: "/apis/detail"
|
||||
api_testbed:
|
||||
name: "테스트베드"
|
||||
path: "/apis/detail/testbed"
|
||||
community:
|
||||
name: 고객지원
|
||||
path: "#"
|
||||
|
||||
@@ -711,75 +711,6 @@ hr {
|
||||
--transition-smooth: all 0.4s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.design-survey-bar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 48px;
|
||||
background: linear-gradient(90deg, #667eea 0%, #764ba2 100%);
|
||||
z-index: 400;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
.design-survey-bar .survey-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
.design-survey-bar .survey-label {
|
||||
color: #ffffff;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.design-survey-bar .survey-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.design-survey-bar .survey-btn {
|
||||
padding: 6px 16px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.5);
|
||||
border-radius: 20px;
|
||||
background: transparent;
|
||||
color: #ffffff;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.design-survey-bar .survey-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-color: #ffffff;
|
||||
}
|
||||
.design-survey-bar .survey-btn.active {
|
||||
background: #ffffff;
|
||||
color: #667eea;
|
||||
border-color: #ffffff;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.design-survey-bar {
|
||||
height: 40px;
|
||||
}
|
||||
.design-survey-bar .survey-label {
|
||||
display: none;
|
||||
}
|
||||
.design-survey-bar .survey-btn {
|
||||
padding: 4px 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
body.design-survey-active .global-header {
|
||||
margin-top: 48px;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
body.design-survey-active .global-header {
|
||||
margin-top: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
.blind {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
@@ -7571,69 +7502,6 @@ button.djb-comment-submit:disabled {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.password-policy-checklist {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 12px 0 0 0;
|
||||
}
|
||||
.password-policy-checklist li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 15px;
|
||||
line-height: 20px;
|
||||
margin-bottom: 6px;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
.password-policy-checklist li:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.password-policy-checklist li .policy-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
.password-policy-checklist li .policy-icon::before {
|
||||
content: "•";
|
||||
}
|
||||
.password-policy-checklist li.is-idle {
|
||||
color: #888;
|
||||
}
|
||||
.password-policy-checklist li.is-idle .policy-icon {
|
||||
color: #b5b5b5;
|
||||
}
|
||||
.password-policy-checklist li.is-pass {
|
||||
color: #1a8f4c;
|
||||
}
|
||||
.password-policy-checklist li.is-pass .policy-icon {
|
||||
color: #1a8f4c;
|
||||
}
|
||||
.password-policy-checklist li.is-pass .policy-icon::before {
|
||||
content: "✔";
|
||||
}
|
||||
.password-policy-checklist li.is-fail {
|
||||
color: #d63a3a;
|
||||
}
|
||||
.password-policy-checklist li.is-fail .policy-icon {
|
||||
color: #d63a3a;
|
||||
}
|
||||
.password-policy-checklist li.is-fail .policy-icon::before {
|
||||
content: "✖";
|
||||
}
|
||||
|
||||
.password-policy-note {
|
||||
margin: 10px 0 0 0;
|
||||
font-size: 14px;
|
||||
line-height: 18px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.board-header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
@@ -7651,6 +7519,330 @@ button.djb-comment-submit:disabled {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.tfa-card {
|
||||
position: relative;
|
||||
width: 520px;
|
||||
max-width: 100%;
|
||||
padding: 0;
|
||||
border: 1px solid #DCE2ED;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
|
||||
color: #0D0E11;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.tfa-close {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: #7F8A95;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
transition: background 0.12s ease, color 0.12s ease;
|
||||
}
|
||||
.tfa-close:hover {
|
||||
background: #F4F5F9;
|
||||
color: #0D0E11;
|
||||
}
|
||||
|
||||
.tfa-head {
|
||||
padding: 24px 32px;
|
||||
padding-right: 60px;
|
||||
border-bottom: 1px solid #DCE2ED;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
.tfa-head .tfa-head-title {
|
||||
font-size: 27px;
|
||||
font-weight: 700;
|
||||
line-height: 1.35;
|
||||
color: #0D0E11;
|
||||
}
|
||||
.tfa-head .tfa-head-brand {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #7F8A95;
|
||||
}
|
||||
|
||||
.tfa-body {
|
||||
padding: 28px 32px 32px;
|
||||
}
|
||||
|
||||
.tfa-step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.tfa-step .tfa-step-num {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 100px;
|
||||
background: #BDC7CF;
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
flex: none;
|
||||
}
|
||||
.tfa-step .tfa-step-num.is-active {
|
||||
background: #4685EF;
|
||||
}
|
||||
.tfa-step .tfa-step-text {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: #7F8A95;
|
||||
}
|
||||
.tfa-step .tfa-step-text.is-active {
|
||||
color: #0D0E11;
|
||||
}
|
||||
|
||||
.tfa-segment {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
.tfa-segment .tfa-seg-btn {
|
||||
padding: 14px 16px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
border: 1px solid #DCE2ED;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
transition: border-color 0.12s ease, background 0.12s ease;
|
||||
}
|
||||
.tfa-segment .tfa-seg-btn .tfa-seg-label {
|
||||
display: block;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: #0D0E11;
|
||||
}
|
||||
.tfa-segment .tfa-seg-btn .tfa-seg-masked {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
font-weight: 300;
|
||||
color: #7F8A95;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.tfa-segment .tfa-seg-btn:hover {
|
||||
border-color: #4685EF;
|
||||
}
|
||||
.tfa-segment .tfa-seg-btn.is-active {
|
||||
border: 1.5px solid #4685EF;
|
||||
background: #ECF0FA;
|
||||
}
|
||||
.tfa-segment .tfa-seg-btn.is-active .tfa-seg-label {
|
||||
color: #2A69DE;
|
||||
}
|
||||
|
||||
.tfa-hint {
|
||||
border: 1px solid #DCE2ED;
|
||||
border-radius: 4px;
|
||||
background: #F4F5F9;
|
||||
padding: 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 300;
|
||||
line-height: 1.5;
|
||||
color: #7F8A95;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.tfa-cta {
|
||||
width: 100%;
|
||||
height: 56px;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
background: #4685EF;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.02em;
|
||||
transition: background 0.12s ease;
|
||||
}
|
||||
.tfa-cta:hover {
|
||||
background: #2A69DE;
|
||||
}
|
||||
.tfa-cta:disabled {
|
||||
background: #F4F5F9;
|
||||
color: #BDC7CF;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.tfa-code-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.tfa-code-row .tfa-code-input-wrap {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 56px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid #BDC7CF;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
transition: border-color 0.12s ease;
|
||||
}
|
||||
.tfa-code-row .tfa-code-input-wrap:focus-within {
|
||||
border-color: #4685EF;
|
||||
}
|
||||
.tfa-code-row .tfa-code-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.02em;
|
||||
color: #0D0E11;
|
||||
}
|
||||
.tfa-code-row .tfa-code-input::placeholder {
|
||||
color: #BDC7CF;
|
||||
font-weight: 400;
|
||||
}
|
||||
.tfa-code-row .tfa-resend {
|
||||
flex: none;
|
||||
height: 56px;
|
||||
padding: 0 18px;
|
||||
border: 1px solid #7F8A95;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
color: #0D0E11;
|
||||
cursor: pointer;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
transition: background 0.12s ease;
|
||||
}
|
||||
.tfa-code-row .tfa-resend:hover {
|
||||
background: #F4F5F9;
|
||||
}
|
||||
.tfa-code-row .tfa-resend:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.tfa-timer-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
border: 1px solid #DCE2ED;
|
||||
border-radius: 4px;
|
||||
background: #F4F5F9;
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.tfa-timer-row .tfa-timer-label {
|
||||
font-size: 14px;
|
||||
font-weight: 300;
|
||||
color: #7F8A95;
|
||||
}
|
||||
.tfa-timer-row .tfa-timer-clock {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
}
|
||||
.tfa-timer-row .tfa-timer {
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.02em;
|
||||
color: #4685EF;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.tfa-timer-row .tfa-timer.is-warning {
|
||||
color: #F4253C;
|
||||
}
|
||||
.tfa-timer-row .tfa-timer.is-expired {
|
||||
color: #7F8A95;
|
||||
}
|
||||
.tfa-timer-row .tfa-timer-total {
|
||||
font-size: 13px;
|
||||
font-weight: 300;
|
||||
color: #7F8A95;
|
||||
}
|
||||
|
||||
.tfa-progress {
|
||||
height: 3px;
|
||||
background: #DCE2ED;
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.tfa-progress .tfa-progress-bar {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background: #4685EF;
|
||||
transition: width 1s linear;
|
||||
}
|
||||
.tfa-progress .tfa-progress-bar.is-warning {
|
||||
background: #F4253C;
|
||||
}
|
||||
|
||||
.tfa-message {
|
||||
min-height: 21px;
|
||||
margin: 0 0 20px;
|
||||
font-size: 14px;
|
||||
font-weight: 300;
|
||||
line-height: 1.5;
|
||||
color: #7F8A95;
|
||||
}
|
||||
.tfa-message.is-error {
|
||||
color: #F4253C;
|
||||
}
|
||||
|
||||
.tfa-test-notice {
|
||||
margin: 0 0 16px;
|
||||
padding: 10px 14px;
|
||||
border: 1px dashed #F59E0B;
|
||||
border-radius: 4px;
|
||||
background: #FFFBEB;
|
||||
color: #B45309;
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.tfa-foot {
|
||||
margin-top: 24px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #DFDFDF;
|
||||
font-size: 14px;
|
||||
font-weight: 300;
|
||||
line-height: 1.5;
|
||||
color: #7F8A95;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.tfa-head {
|
||||
padding: 20px 22px;
|
||||
}
|
||||
.tfa-head .tfa-head-title {
|
||||
font-size: 23px;
|
||||
}
|
||||
.tfa-body {
|
||||
padding: 22px 22px 26px;
|
||||
}
|
||||
.tfa-segment {
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
}
|
||||
.hero-carousel-section {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
@@ -10975,6 +11167,8 @@ body.index-page-body {
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
bottom: -2px;
|
||||
display: inline-block;
|
||||
text-decoration: none;
|
||||
}
|
||||
.api-detail-tabs .tab-button:hover {
|
||||
color: #0049b4;
|
||||
@@ -11003,6 +11197,39 @@ body.index-page-body {
|
||||
margin: 24px 0;
|
||||
}
|
||||
|
||||
.testbed-auth-gate {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
padding: calc(32px * 2) 24px;
|
||||
text-align: center;
|
||||
background: #FFFFFF;
|
||||
border: 1px solid #E2E8F0;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
.testbed-auth-gate .testbed-auth-gate__icon {
|
||||
font-size: 40px;
|
||||
line-height: 1;
|
||||
}
|
||||
.testbed-auth-gate .testbed-auth-gate__title {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #1A1A2E;
|
||||
}
|
||||
.testbed-auth-gate .testbed-auth-gate__desc {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
color: #64748B;
|
||||
}
|
||||
.testbed-auth-gate .btn {
|
||||
margin-top: 8px;
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
.testbed-app-panel {
|
||||
margin: 8px 0 0;
|
||||
padding: 24px;
|
||||
@@ -11452,11 +11679,11 @@ body.index-page-body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 80px 20px;
|
||||
padding: 100px 20px;
|
||||
position: relative;
|
||||
border-radius: 12px;
|
||||
margin-top: 60px;
|
||||
margin-bottom: 60px;
|
||||
margin-bottom: 50px;
|
||||
}
|
||||
|
||||
.login-container {
|
||||
@@ -11751,7 +11978,7 @@ body.index-page-body {
|
||||
@media (max-width: 768px) {
|
||||
.login-page {
|
||||
min-height: calc(100vh - 44px);
|
||||
padding: 40px 20px;
|
||||
padding: 100px 20px;
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
border-radius: 0;
|
||||
|
||||
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,348 @@
|
||||
/*
|
||||
* 공통 2FA(추가 인증) 팝업 모듈.
|
||||
*
|
||||
* TwoFactorAuth.open({
|
||||
* mode: 'login' | 'stepup', // 참고용(서버가 세션으로 판별). 로깅/분기용
|
||||
* purpose: '/myapikey/...', // step-up 대상 보호 경로(로그인은 생략)
|
||||
* onSuccess: function(res){}, // 검증 성공. login 이면 res.redirect 사용
|
||||
* onCancel: function(reason){}// 닫기/타임아웃/실패로 종료
|
||||
* });
|
||||
*
|
||||
* 수신처는 서버가 세션 대상 사용자로부터 결정한다(클라이언트는 채널만 선택).
|
||||
* 모든 POST 는 세션 CSRF(meta[name=_csrf]) 헤더를 함께 보낸다.
|
||||
*/
|
||||
(function (global) {
|
||||
'use strict';
|
||||
|
||||
var CTX = (function () {
|
||||
var el = document.querySelector('base');
|
||||
return (window.__contextPath !== undefined) ? window.__contextPath : '';
|
||||
})();
|
||||
|
||||
function csrf() {
|
||||
var t = document.querySelector('meta[name="_csrf"]');
|
||||
var h = document.querySelector('meta[name="_csrf_header"]');
|
||||
return {
|
||||
header: h ? h.getAttribute('content') : 'X-XSRF-TOKEN',
|
||||
token: t ? t.getAttribute('content') : ''
|
||||
};
|
||||
}
|
||||
|
||||
function url(path) {
|
||||
return CTX + path;
|
||||
}
|
||||
|
||||
function postForm(path, params) {
|
||||
var c = csrf();
|
||||
var headers = { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' };
|
||||
if (c.token) { headers[c.header] = c.token; }
|
||||
var body = Object.keys(params || {}).map(function (k) {
|
||||
return encodeURIComponent(k) + '=' + encodeURIComponent(params[k]);
|
||||
}).join('&');
|
||||
return fetch(url(path), {
|
||||
method: 'POST',
|
||||
headers: headers,
|
||||
credentials: 'same-origin',
|
||||
body: body
|
||||
}).then(function (r) {
|
||||
// cancel 은 본문이 없다
|
||||
return r.status === 200 ? r.text().then(function (t) { return t ? JSON.parse(t) : {}; }) : Promise.reject(r);
|
||||
});
|
||||
}
|
||||
|
||||
function getJson(path) {
|
||||
return fetch(url(path), { credentials: 'same-origin' }).then(function (r) { return r.json(); });
|
||||
}
|
||||
|
||||
var TwoFactorAuth = {
|
||||
_opts: null,
|
||||
_timer: null,
|
||||
_channel: null,
|
||||
_closing: false,
|
||||
|
||||
open: function (options) {
|
||||
var self = this;
|
||||
self._opts = options || {};
|
||||
self._closing = false;
|
||||
var purpose = self._opts.purpose || '';
|
||||
|
||||
getJson('/auth/2fa/info' + (purpose ? ('?purpose=' + encodeURIComponent(purpose)) : ''))
|
||||
.then(function (info) {
|
||||
if (!info || !info.available) {
|
||||
self._fail('인증 대상 정보가 없습니다. 다시 시도해주세요.');
|
||||
return;
|
||||
}
|
||||
self._render(info);
|
||||
self._show();
|
||||
if (info.inProgress) {
|
||||
self._confirmForce(info.message || '진행 중인 다른 인증 절차가 있습니다.');
|
||||
}
|
||||
})
|
||||
.catch(function () {
|
||||
self._fail('추가 인증을 시작할 수 없습니다.');
|
||||
});
|
||||
},
|
||||
|
||||
_render: function (info) {
|
||||
var self = this;
|
||||
self._ttl = info.ttlSeconds || 180;
|
||||
self._testNotice = !!info.testNoticeEnabled;
|
||||
|
||||
var seg = document.getElementById('tfaSegment');
|
||||
seg.innerHTML = '';
|
||||
self._maskedByType = {};
|
||||
var channels = info.channels || [];
|
||||
channels.forEach(function (ch, idx) {
|
||||
self._maskedByType[ch.type] = ch.masked;
|
||||
var btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'tfa-seg-btn' + (idx === 0 ? ' is-active' : '');
|
||||
btn.setAttribute('data-channel', ch.type);
|
||||
btn.innerHTML = '<span class="tfa-seg-label">' + (ch.type === 'EMAIL' ? '이메일' : '휴대폰 문자') + '</span>'
|
||||
+ '<span class="tfa-seg-masked">' + ch.masked + '</span>';
|
||||
btn.addEventListener('click', function () { self._selectChannel(ch.type); });
|
||||
seg.appendChild(btn);
|
||||
});
|
||||
self._channel = channels.length ? channels[0].type : null;
|
||||
|
||||
// 초기 상태: 발송 단계
|
||||
document.getElementById('tfaSendStep').style.display = '';
|
||||
document.getElementById('tfaVerifyStep').style.display = 'none';
|
||||
self._setMessage('', false);
|
||||
document.getElementById('tfaCodeInput').value = '';
|
||||
document.getElementById('tfaVerifyButton').disabled = true;
|
||||
self._setStep2Active(false);
|
||||
self._resetTimerUi();
|
||||
var notice = document.getElementById('tfaTestNotice');
|
||||
notice.style.display = 'none';
|
||||
notice.textContent = '';
|
||||
|
||||
// 핸들러 바인딩
|
||||
document.getElementById('tfaSendButton').onclick = function () { self._send(false); };
|
||||
document.getElementById('tfaResendButton').onclick = function () { self._send(false); };
|
||||
document.getElementById('tfaVerifyButton').onclick = function () { self._verify(); };
|
||||
document.getElementById('tfaCloseButton').onclick = function () { self._cancel('CANCELLED'); };
|
||||
document.getElementById('tfaBackdrop').onclick = function () { self._cancel('CANCELLED'); };
|
||||
document.getElementById('tfaCodeInput').oninput = function () {
|
||||
var v = (this.value || '').replace(/\D/g, '').slice(0, 6);
|
||||
this.value = v;
|
||||
document.getElementById('tfaVerifyButton').disabled = v.length < 6;
|
||||
};
|
||||
document.getElementById('tfaCodeInput').onkeydown = function (e) {
|
||||
if (e.key === 'Enter') { self._verify(); }
|
||||
};
|
||||
},
|
||||
|
||||
_selectChannel: function (type) {
|
||||
this._channel = type;
|
||||
var btns = document.querySelectorAll('#tfaSegment .tfa-seg-btn');
|
||||
Array.prototype.forEach.call(btns, function (b) {
|
||||
b.classList.toggle('is-active', b.getAttribute('data-channel') === type);
|
||||
});
|
||||
},
|
||||
|
||||
_send: function (force) {
|
||||
var self = this;
|
||||
if (!self._channel) { return; }
|
||||
var sendBtn = document.getElementById('tfaSendButton');
|
||||
var resendBtn = document.getElementById('tfaResendButton');
|
||||
sendBtn.disabled = true;
|
||||
resendBtn.disabled = true;
|
||||
|
||||
postForm('/auth/2fa/send', {
|
||||
channel: self._channel,
|
||||
purpose: self._opts.purpose || '',
|
||||
force: force ? 'true' : 'false'
|
||||
}).then(function (res) {
|
||||
sendBtn.disabled = false;
|
||||
resendBtn.disabled = false;
|
||||
if (res.inProgress) {
|
||||
self._confirmForce(res.message || '진행 중인 다른 인증 절차가 있습니다.');
|
||||
return;
|
||||
}
|
||||
if (!res.valid) {
|
||||
self._setMessage(res.message || '인증번호 발송에 실패했습니다.', true);
|
||||
return;
|
||||
}
|
||||
// 발송 성공 → 검증 단계 노출 + 타이머
|
||||
document.getElementById('tfaSendStep').style.display = 'none';
|
||||
document.getElementById('tfaVerifyStep').style.display = '';
|
||||
self._setStep2Active(true);
|
||||
var masked = self._maskedByType ? (self._maskedByType[self._channel] || '') : '';
|
||||
self._setMessage((masked ? masked + ' 으로 ' : '') + '인증번호를 보냈습니다.', false);
|
||||
document.getElementById('tfaCodeInput').value = '';
|
||||
document.getElementById('tfaVerifyButton').disabled = true;
|
||||
document.getElementById('tfaCodeInput').focus();
|
||||
if (self._testNotice && res.testAuthNumber) {
|
||||
var notice = document.getElementById('tfaTestNotice');
|
||||
notice.style.display = '';
|
||||
notice.textContent = '[테스트] 인증번호: ' + res.testAuthNumber;
|
||||
}
|
||||
self._startTimer(res.ttlSeconds || self._ttl);
|
||||
}).catch(function () {
|
||||
sendBtn.disabled = false;
|
||||
resendBtn.disabled = false;
|
||||
self._setMessage('인증번호 발송 중 오류가 발생했습니다.', true);
|
||||
});
|
||||
},
|
||||
|
||||
_confirmForce: function (message) {
|
||||
var self = this;
|
||||
var ok = window.confirm(message + '\n강제 종료하고 새로 진행하시겠습니까?');
|
||||
if (ok) {
|
||||
self._send(true);
|
||||
}
|
||||
},
|
||||
|
||||
_verify: function () {
|
||||
var self = this;
|
||||
var code = (document.getElementById('tfaCodeInput').value || '').trim();
|
||||
if (!/^[0-9]{6}$/.test(code)) {
|
||||
self._setMessage('6자리 인증번호를 입력해주세요.', true);
|
||||
return;
|
||||
}
|
||||
var btn = document.getElementById('tfaVerifyButton');
|
||||
btn.disabled = true;
|
||||
postForm('/auth/2fa/verify', { code: code }).then(function (res) {
|
||||
btn.disabled = false;
|
||||
if (res.valid) {
|
||||
self._stopTimer();
|
||||
self._closing = true;
|
||||
self._hide();
|
||||
if (typeof self._opts.onSuccess === 'function') {
|
||||
self._opts.onSuccess(res);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (res.terminated) {
|
||||
self._stopTimer();
|
||||
self._fail(res.message || '인증에 실패했습니다. 처음부터 다시 진행해주세요.');
|
||||
return;
|
||||
}
|
||||
self._setMessage(res.message || '인증번호가 일치하지 않습니다.', true);
|
||||
}).catch(function () {
|
||||
btn.disabled = false;
|
||||
self._setMessage('인증 처리 중 오류가 발생했습니다.', true);
|
||||
});
|
||||
},
|
||||
|
||||
_cancel: function (reason) {
|
||||
var self = this;
|
||||
if (self._closing) { return; }
|
||||
self._closing = true;
|
||||
self._stopTimer();
|
||||
postForm('/auth/2fa/cancel', { reason: reason }).catch(function () {}).then(function () {
|
||||
self._hide();
|
||||
if (typeof self._opts.onCancel === 'function') {
|
||||
self._opts.onCancel(reason);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
_fmtClock: function (n) {
|
||||
var m = Math.floor(n / 60);
|
||||
var s = n % 60;
|
||||
return m + ':' + (s < 10 ? '0' + s : s);
|
||||
},
|
||||
|
||||
_setStep2Active: function (active) {
|
||||
var num = document.getElementById('tfaStepNum');
|
||||
var text = document.getElementById('tfaStepText');
|
||||
if (num) { num.classList.toggle('is-active', active); }
|
||||
if (text) { text.classList.toggle('is-active', active); }
|
||||
},
|
||||
|
||||
_resetTimerUi: function () {
|
||||
var base = this._ttl || 180;
|
||||
var clock = document.getElementById('tfaTimer');
|
||||
var bar = document.getElementById('tfaProgressBar');
|
||||
var label = document.getElementById('tfaTimerLabel');
|
||||
var total = document.getElementById('tfaTimerTotal');
|
||||
if (clock) { clock.className = 'tfa-timer'; clock.textContent = this._fmtClock(base); }
|
||||
if (bar) { bar.className = 'tfa-progress-bar'; bar.style.width = '100%'; }
|
||||
if (label) { label.textContent = '남은 인증 시간'; }
|
||||
if (total) { total.textContent = '/ ' + this._fmtClock(base); }
|
||||
},
|
||||
|
||||
_startTimer: function (seconds) {
|
||||
var self = this;
|
||||
self._stopTimer();
|
||||
var total = seconds || self._ttl || 180;
|
||||
var remaining = seconds;
|
||||
var clock = document.getElementById('tfaTimer');
|
||||
var bar = document.getElementById('tfaProgressBar');
|
||||
var label = document.getElementById('tfaTimerLabel');
|
||||
var totalEl = document.getElementById('tfaTimerTotal');
|
||||
if (label) { label.textContent = '남은 인증 시간'; }
|
||||
if (totalEl) { totalEl.textContent = '/ ' + self._fmtClock(total); }
|
||||
function tick() {
|
||||
if (remaining <= 0) {
|
||||
self._stopTimer();
|
||||
if (clock) { clock.textContent = '0:00'; clock.className = 'tfa-timer is-expired'; }
|
||||
if (bar) { bar.style.width = '0%'; }
|
||||
if (label) { label.textContent = '인증 시간이 만료되었습니다. 재전송해 주세요.'; }
|
||||
self._cancel('TIMEOUT');
|
||||
return;
|
||||
}
|
||||
var warn = remaining <= 30;
|
||||
if (clock) {
|
||||
clock.textContent = self._fmtClock(remaining);
|
||||
clock.className = 'tfa-timer' + (warn ? ' is-warning' : '');
|
||||
}
|
||||
if (bar) {
|
||||
bar.style.width = Math.round((remaining / total) * 100) + '%';
|
||||
bar.className = 'tfa-progress-bar' + (warn ? ' is-warning' : '');
|
||||
}
|
||||
remaining--;
|
||||
}
|
||||
tick();
|
||||
self._timer = setInterval(tick, 1000);
|
||||
},
|
||||
|
||||
_stopTimer: function () {
|
||||
if (this._timer) {
|
||||
clearInterval(this._timer);
|
||||
this._timer = null;
|
||||
}
|
||||
},
|
||||
|
||||
_setMessage: function (msg, isError) {
|
||||
var el = document.getElementById('tfaMessage');
|
||||
el.textContent = msg || '';
|
||||
el.className = 'tfa-message' + (isError ? ' is-error' : '');
|
||||
},
|
||||
|
||||
_fail: function (message) {
|
||||
var self = this;
|
||||
self._stopTimer();
|
||||
self._hide();
|
||||
if (typeof window.customPopups !== 'undefined' && customPopups.showAlert) {
|
||||
customPopups.showAlert(message);
|
||||
} else if (message) {
|
||||
window.alert(message);
|
||||
}
|
||||
if (typeof self._opts.onCancel === 'function') {
|
||||
self._opts.onCancel('FAILED');
|
||||
}
|
||||
},
|
||||
|
||||
_show: function () {
|
||||
// 모달은 컨테이너 display + backdrop/modal 의 .show 클래스(visibility/opacity) 둘 다 필요
|
||||
document.getElementById('tfaPopup').style.display = 'block';
|
||||
var bd = document.getElementById('tfaBackdrop');
|
||||
var md = document.getElementById('tfaModal');
|
||||
if (bd) bd.classList.add('show');
|
||||
if (md) md.classList.add('show');
|
||||
},
|
||||
|
||||
_hide: function () {
|
||||
var bd = document.getElementById('tfaBackdrop');
|
||||
var md = document.getElementById('tfaModal');
|
||||
if (bd) bd.classList.remove('show');
|
||||
if (md) md.classList.remove('show');
|
||||
document.getElementById('tfaPopup').style.display = 'none';
|
||||
}
|
||||
};
|
||||
|
||||
global.TwoFactorAuth = TwoFactorAuth;
|
||||
})(window);
|
||||
@@ -0,0 +1,353 @@
|
||||
@use '../abstracts/variables' as *;
|
||||
@use '../abstracts/color-functions' as *;
|
||||
@use '../abstracts/mixins' as *;
|
||||
|
||||
// 공통 2FA(추가 인증) 팝업 — 제주은행(DJBank) ERP뱅킹 Design Guide 적용.
|
||||
// .modal / .modal-backdrop / .modal 은 _modals 의 show/hide·애니메이션을 재사용하고
|
||||
// 카드 내부는 아래 토큰(surface_blue #4685EF · border_gray #DCE2ED · radius 4)으로 재정의한다.
|
||||
|
||||
// 디자인 토큰(가이드 전용, 전역 변수와 분리)
|
||||
$tfa-blue: #4685EF;
|
||||
$tfa-blue-dark: #2A69DE;
|
||||
$tfa-blue-soft: #ECF0FA;
|
||||
$tfa-blue-text: #2A69DE;
|
||||
$tfa-ink: #0D0E11;
|
||||
$tfa-muted: #7F8A95;
|
||||
$tfa-border: #DCE2ED;
|
||||
$tfa-border-2: #BDC7CF;
|
||||
$tfa-surface: #F4F5F9;
|
||||
$tfa-danger: #F4253C;
|
||||
|
||||
// 카드 (modal-dialog 기본 padding/max-width/radius 를 덮어씀)
|
||||
.tfa-card {
|
||||
position: relative;
|
||||
width: 520px;
|
||||
max-width: 100%;
|
||||
padding: 0;
|
||||
border: 1px solid $tfa-border;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
|
||||
color: $tfa-ink;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
// 닫기 (우상단, 은은한 회색)
|
||||
.tfa-close {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: $tfa-muted;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
transition: background 0.12s ease, color 0.12s ease;
|
||||
|
||||
&:hover {
|
||||
background: $tfa-surface;
|
||||
color: $tfa-ink;
|
||||
}
|
||||
}
|
||||
|
||||
// 헤더
|
||||
.tfa-head {
|
||||
padding: 24px 32px;
|
||||
padding-right: 60px; // 우상단 닫기(✕) 영역 확보 — 브랜드 텍스트와 겹치지 않게
|
||||
border-bottom: 1px solid $tfa-border;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
|
||||
.tfa-head-title {
|
||||
font-size: 27px;
|
||||
font-weight: 700;
|
||||
line-height: 1.35;
|
||||
color: $tfa-ink;
|
||||
}
|
||||
|
||||
.tfa-head-brand {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: $tfa-muted;
|
||||
}
|
||||
}
|
||||
|
||||
.tfa-body {
|
||||
padding: 28px 32px 32px;
|
||||
}
|
||||
|
||||
// 단계 라벨 (① 인증 수단 / ② 인증번호 입력)
|
||||
.tfa-step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
|
||||
.tfa-step-num {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 100px;
|
||||
background: $tfa-border-2;
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
flex: none;
|
||||
|
||||
&.is-active { background: $tfa-blue; }
|
||||
}
|
||||
|
||||
.tfa-step-text {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: $tfa-muted;
|
||||
|
||||
&.is-active { color: $tfa-ink; }
|
||||
}
|
||||
}
|
||||
|
||||
// 채널 선택 (이메일 / 휴대폰) — 2열 그리드
|
||||
.tfa-segment {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
margin-bottom: 28px;
|
||||
|
||||
.tfa-seg-btn {
|
||||
padding: 14px 16px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
border: 1px solid $tfa-border;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
transition: border-color 0.12s ease, background 0.12s ease;
|
||||
|
||||
.tfa-seg-label {
|
||||
display: block;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: $tfa-ink;
|
||||
}
|
||||
|
||||
.tfa-seg-masked {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
font-weight: 300;
|
||||
color: $tfa-muted;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
&:hover { border-color: $tfa-blue; }
|
||||
|
||||
&.is-active {
|
||||
border: 1.5px solid $tfa-blue;
|
||||
background: $tfa-blue-soft;
|
||||
|
||||
.tfa-seg-label { color: $tfa-blue-text; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 안내 박스 (발송 전)
|
||||
.tfa-hint {
|
||||
border: 1px solid $tfa-border;
|
||||
border-radius: 4px;
|
||||
background: $tfa-surface;
|
||||
padding: 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 300;
|
||||
line-height: 1.5;
|
||||
color: $tfa-muted;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
// 기본 CTA (인증번호 받기 / 인증 완료)
|
||||
.tfa-cta {
|
||||
width: 100%;
|
||||
height: 56px;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
background: $tfa-blue;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.02em;
|
||||
transition: background 0.12s ease;
|
||||
|
||||
&:hover { background: $tfa-blue-dark; }
|
||||
|
||||
&:disabled {
|
||||
background: $tfa-surface;
|
||||
color: $tfa-border-2;
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
// 인증번호 입력 + 재전송
|
||||
.tfa-code-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
|
||||
.tfa-code-input-wrap {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 56px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid $tfa-border-2;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
transition: border-color 0.12s ease;
|
||||
|
||||
&:focus-within { border-color: $tfa-blue; }
|
||||
}
|
||||
|
||||
.tfa-code-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.02em;
|
||||
color: $tfa-ink;
|
||||
|
||||
&::placeholder {
|
||||
color: $tfa-border-2;
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
|
||||
.tfa-resend {
|
||||
flex: none;
|
||||
height: 56px;
|
||||
padding: 0 18px;
|
||||
border: 1px solid $tfa-muted;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
color: $tfa-ink;
|
||||
cursor: pointer;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
transition: background 0.12s ease;
|
||||
|
||||
&:hover { background: $tfa-surface; }
|
||||
&:disabled { opacity: 0.5; cursor: default; }
|
||||
}
|
||||
}
|
||||
|
||||
// 타이머 행
|
||||
.tfa-timer-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
border: 1px solid $tfa-border;
|
||||
border-radius: 4px;
|
||||
background: $tfa-surface;
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 12px;
|
||||
|
||||
.tfa-timer-label {
|
||||
font-size: 14px;
|
||||
font-weight: 300;
|
||||
color: $tfa-muted;
|
||||
}
|
||||
|
||||
.tfa-timer-clock {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.tfa-timer {
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.02em;
|
||||
color: $tfa-blue;
|
||||
font-variant-numeric: tabular-nums;
|
||||
|
||||
&.is-warning { color: $tfa-danger; }
|
||||
&.is-expired { color: $tfa-muted; }
|
||||
}
|
||||
|
||||
.tfa-timer-total {
|
||||
font-size: 13px;
|
||||
font-weight: 300;
|
||||
color: $tfa-muted;
|
||||
}
|
||||
}
|
||||
|
||||
// 진행 바
|
||||
.tfa-progress {
|
||||
height: 3px;
|
||||
background: $tfa-border;
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 12px;
|
||||
|
||||
.tfa-progress-bar {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background: $tfa-blue;
|
||||
transition: width 1s linear;
|
||||
|
||||
&.is-warning { background: $tfa-danger; }
|
||||
}
|
||||
}
|
||||
|
||||
// 발송 안내 / 오류 메시지
|
||||
.tfa-message {
|
||||
min-height: 21px;
|
||||
margin: 0 0 20px;
|
||||
font-size: 14px;
|
||||
font-weight: 300;
|
||||
line-height: 1.5;
|
||||
color: $tfa-muted;
|
||||
|
||||
&.is-error { color: $tfa-danger; }
|
||||
}
|
||||
|
||||
// 테스트 인증번호 노출(개발/스테이지)
|
||||
.tfa-test-notice {
|
||||
margin: 0 0 16px;
|
||||
padding: 10px 14px;
|
||||
border: 1px dashed #F59E0B;
|
||||
border-radius: 4px;
|
||||
background: #FFFBEB;
|
||||
color: #B45309;
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
// 하단 안내
|
||||
.tfa-foot {
|
||||
margin-top: 24px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #DFDFDF;
|
||||
font-size: 14px;
|
||||
font-weight: 300;
|
||||
line-height: 1.5;
|
||||
color: $tfa-muted;
|
||||
}
|
||||
|
||||
@media (max-width: $breakpoint-sm) {
|
||||
.tfa-head { padding: 20px 22px; }
|
||||
.tfa-head .tfa-head-title { font-size: 23px; }
|
||||
.tfa-body { padding: 22px 22px 26px; }
|
||||
.tfa-segment { margin-bottom: 22px; }
|
||||
}
|
||||
@@ -44,92 +44,6 @@
|
||||
--transition-smooth: all 0.4s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
// ===========================
|
||||
// Design Survey Bar
|
||||
// ===========================
|
||||
.design-survey-bar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 48px;
|
||||
background: linear-gradient(90deg, #667eea 0%, #764ba2 100%);
|
||||
z-index: 400;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
|
||||
.survey-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.survey-label {
|
||||
color: #ffffff;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.survey-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.survey-btn {
|
||||
padding: 6px 16px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.5);
|
||||
border-radius: 20px;
|
||||
background: transparent;
|
||||
color: #ffffff;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-color: #ffffff;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: #ffffff;
|
||||
color: #667eea;
|
||||
border-color: #ffffff;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
height: 40px;
|
||||
|
||||
.survey-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.survey-btn {
|
||||
padding: 4px 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Body offset when survey is active
|
||||
body.design-survey-active {
|
||||
.global-header {
|
||||
margin-top: 48px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.global-header {
|
||||
margin-top: 40px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 디자인 변형 스타일은 JavaScript에서 동적으로 적용됩니다.
|
||||
// header_container.html의 DESIGN_OPTIONS 참조
|
||||
|
||||
// Blind text for screen readers
|
||||
.blind {
|
||||
position: absolute;
|
||||
|
||||
@@ -44,8 +44,8 @@
|
||||
@use 'components/breadcrumb' as *;
|
||||
@use 'components/test-env-notice' as *;
|
||||
@use 'components/djb-inquiry-comments' as *;
|
||||
@use 'components/password-policy' as *;
|
||||
@use 'components/board-common' as *;
|
||||
@use 'components/two-factor' as *;
|
||||
|
||||
// 5. Page-specific styles
|
||||
@use 'pages/index' as *;
|
||||
|
||||
@@ -645,6 +645,9 @@
|
||||
transition: $transition-base;
|
||||
position: relative;
|
||||
bottom: -2px;
|
||||
// 탭이 <a> 링크로 전환됨(별도 URL) — 링크 기본 스타일 제거
|
||||
display: inline-block;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
color: $primary-blue;
|
||||
@@ -680,6 +683,44 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Testbed 미인증 안내 — UI는 노출하되 접근 시 로그인 요구
|
||||
.testbed-auth-gate {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: $spacing-md;
|
||||
padding: calc(#{$spacing-xl} * 2) $spacing-lg;
|
||||
text-align: center;
|
||||
background: $white;
|
||||
border: 1px solid $border-gray;
|
||||
border-radius: $border-radius-lg;
|
||||
box-shadow: $shadow-sm;
|
||||
|
||||
.testbed-auth-gate__icon {
|
||||
font-size: 40px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.testbed-auth-gate__title {
|
||||
margin: 0;
|
||||
font-size: $font-size-lg;
|
||||
font-weight: $font-weight-bold;
|
||||
color: $text-dark;
|
||||
}
|
||||
|
||||
.testbed-auth-gate__desc {
|
||||
margin: 0;
|
||||
font-size: $font-size-base;
|
||||
color: $text-gray;
|
||||
}
|
||||
|
||||
.btn {
|
||||
margin-top: $spacing-sm;
|
||||
min-width: 160px;
|
||||
}
|
||||
}
|
||||
|
||||
// Testbed 앱 선택 패널 (DJPGPT0001)
|
||||
.testbed-app-panel {
|
||||
margin: $spacing-sm 0 0;
|
||||
|
||||
@@ -55,14 +55,18 @@
|
||||
<!-- API Content -->
|
||||
<div class="api-detail-content">
|
||||
|
||||
<!-- Tab Navigation -->
|
||||
<!-- Tab Navigation: API 정보 / 테스트베드는 각각 별도 URL(딥링크). 활성 탭은 서버에서 결정 -->
|
||||
<div class="api-detail-tabs">
|
||||
<button class="tab-button active" data-tab="api-info">API 정보</button>
|
||||
<button class="tab-button" data-tab="testbed">테스트베드</button>
|
||||
<a class="tab-button"
|
||||
th:classappend="${activeTab == 'testbed'} ? '' : 'active'"
|
||||
th:href="@{/apis/detail(id=${apiSpecInfo.apiId})}">API 정보</a>
|
||||
<a class="tab-button"
|
||||
th:classappend="${activeTab == 'testbed'} ? 'active' : ''"
|
||||
th:href="@{/apis/detail/testbed(id=${apiSpecInfo.apiId})}">테스트베드</a>
|
||||
</div>
|
||||
|
||||
<!-- Tab Content -->
|
||||
<div class="tab-content active" id="api-info-tab">
|
||||
<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">
|
||||
@@ -141,26 +145,38 @@
|
||||
</div>
|
||||
|
||||
<!-- Testbed Tab Content -->
|
||||
<div class="tab-content" id="testbed-tab">
|
||||
<link rel="stylesheet" type="text/css" th:href="@{/plugins/swaggerUI/swagger-ui.css}"/>
|
||||
<link rel="stylesheet" type="text/css" th:href="@{/plugins/swaggerUI/index.css}"/>
|
||||
<link rel="stylesheet" type="text/css" th:href="@{/plugins/swaggerUI/djb-swagger-testbed.css}"/>
|
||||
<div class="tab-content" th:classappend="${activeTab == 'testbed'} ? 'active' : ''" id="testbed-tab">
|
||||
|
||||
<!-- DJPGPT0001: 앱(인증키) 선택 → 인증 정보 자동 주입 -->
|
||||
<div class="testbed-app-panel" id="appsWrap">
|
||||
<div class="testbed-app-panel__head">
|
||||
<span class="testbed-app-panel__title">앱 선택</span>
|
||||
<span class="testbed-app-panel__desc">테스트할 앱(인증키)을 선택하면 인증 정보가 자동 입력됩니다.</span>
|
||||
</div>
|
||||
<div class="testbed-app-field">
|
||||
<select id="apps">
|
||||
<option value="">앱을 선택하세요</option>
|
||||
</select>
|
||||
</div>
|
||||
<p class="testbed-app-notice" id="appsNotice" style="display:none;"></p>
|
||||
<!-- 미인증: 테스트베드는 로그인 후 이용 가능. UI는 노출하되 접근 시 인증 요구 -->
|
||||
<div class="testbed-auth-gate" th:unless="${authenticated}">
|
||||
<div class="testbed-auth-gate__icon">🔒</div>
|
||||
<p class="testbed-auth-gate__title">테스트베드는 로그인 후 이용 가능합니다.</p>
|
||||
<p class="testbed-auth-gate__desc">로그인하면 본인 앱(인증키)으로 API를 직접 호출·테스트할 수 있습니다.</p>
|
||||
<a class="btn btn-primary" th:href="@{/login(reason='auth')}">로그인하기</a>
|
||||
</div>
|
||||
|
||||
<div id="swagger-ui"></div>
|
||||
<!-- 인증: Swagger 테스트베드 -->
|
||||
<th:block th:if="${authenticated}">
|
||||
<link rel="stylesheet" type="text/css" th:href="@{/plugins/swaggerUI/swagger-ui.css}"/>
|
||||
<link rel="stylesheet" type="text/css" th:href="@{/plugins/swaggerUI/index.css}"/>
|
||||
<link rel="stylesheet" type="text/css" th:href="@{/plugins/swaggerUI/djb-swagger-testbed.css}"/>
|
||||
|
||||
<!-- DJPGPT0001: 앱(인증키) 선택 → 인증 정보 자동 주입 -->
|
||||
<div class="testbed-app-panel" id="appsWrap">
|
||||
<div class="testbed-app-panel__head">
|
||||
<span class="testbed-app-panel__title">앱 선택</span>
|
||||
<span class="testbed-app-panel__desc">테스트할 앱(인증키)을 선택하면 인증 정보가 자동 입력됩니다.</span>
|
||||
</div>
|
||||
<div class="testbed-app-field">
|
||||
<select id="apps">
|
||||
<option value="">앱을 선택하세요</option>
|
||||
</select>
|
||||
</div>
|
||||
<p class="testbed-app-notice" id="appsNotice" style="display:none;"></p>
|
||||
</div>
|
||||
|
||||
<div id="swagger-ui"></div>
|
||||
</th:block>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -255,42 +271,13 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Tab functionality
|
||||
const tabButtons = document.querySelectorAll('.tab-button');
|
||||
const tabContents = document.querySelectorAll('.tab-content');
|
||||
|
||||
tabButtons.forEach(function(button) {
|
||||
button.addEventListener('click', function() {
|
||||
const targetTab = this.getAttribute('data-tab');
|
||||
|
||||
// Remove active class from all buttons and contents
|
||||
tabButtons.forEach(function(btn) {
|
||||
btn.classList.remove('active');
|
||||
});
|
||||
tabContents.forEach(function(content) {
|
||||
content.classList.remove('active');
|
||||
});
|
||||
|
||||
// Add active class to clicked button and corresponding content
|
||||
this.classList.add('active');
|
||||
document.getElementById(targetTab + '-tab').classList.add('active');
|
||||
});
|
||||
});
|
||||
|
||||
// Swagger UI functionality for testbed tab
|
||||
let swaggerInitialized = false;
|
||||
// 탭(API 정보 / 테스트베드)은 각각 별도 URL의 <a> 링크로 전환된다.
|
||||
// 활성 탭은 서버가 결정하고, 테스트베드 탭이 활성이면서 인증된 경우에만
|
||||
// Swagger UI를 초기화한다(실제 초기화는 하단에서 실행 — const 선언 이후).
|
||||
const currentApiId = /*[[${apiSpecInfo.apiId}]]*/ 'default';
|
||||
|
||||
// Initialize Swagger UI when testbed tab is clicked
|
||||
tabButtons.forEach(function(button) {
|
||||
button.addEventListener('click', function() {
|
||||
const targetTab = this.getAttribute('data-tab');
|
||||
if (targetTab === 'testbed' && !swaggerInitialized) {
|
||||
initializeSwagger(currentApiId);
|
||||
swaggerInitialized = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
const activeTab = /*[[${activeTab}]]*/ 'api-info';
|
||||
const isAuthenticated = /*[[${authenticated}]]*/ false;
|
||||
const testbedActive = (activeTab === 'testbed' && isAuthenticated);
|
||||
|
||||
function initializeSwagger(apiId) {
|
||||
if (!apiId || apiId === 'default') {
|
||||
@@ -517,10 +504,14 @@
|
||||
.catch(function (e) { console.error('앱 인증정보 주입 실패', e); });
|
||||
}
|
||||
|
||||
if (appsSelect) {
|
||||
appsSelect.addEventListener('change', function () { onAppSelected(this.value); });
|
||||
// 테스트베드 탭 활성 + 인증 상태일 때만 Swagger UI/앱 인증 컨텍스트 초기화
|
||||
if (testbedActive) {
|
||||
if (appsSelect) {
|
||||
appsSelect.addEventListener('change', function () { onAppSelected(this.value); });
|
||||
}
|
||||
initializeSwagger(currentApiId);
|
||||
loadTestbedContext();
|
||||
}
|
||||
loadTestbedContext();
|
||||
});
|
||||
</script>
|
||||
</th:block>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<section class="service-hero">
|
||||
<div class="service-hero__inner">
|
||||
<div class="service-hero__icon-wrapper">
|
||||
<img th:src="@{/img/keyimage/api_img.png}" alt="OPEN API 3D 아이콘" class="service-keyImg" />
|
||||
<img th:src="@{/img/keyimage/api_img.svg}" alt="OPEN API 3D 아이콘" class="service-keyImg" />
|
||||
</div>
|
||||
<div class="service-hero__content">
|
||||
<div class="service-hero__badge">
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns:th="http://www.thymeleaf.org"
|
||||
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>
|
||||
<section layout:fragment="contentFragment">
|
||||
<div class="service-main">
|
||||
<div class="app-management-content">
|
||||
<div class="password-change-wrapper">
|
||||
<h2 class="page-outer-title">본인 확인</h2>
|
||||
|
||||
<form th:action="@{/auth/stepup/password}" method="post">
|
||||
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/>
|
||||
<input type="hidden" name="returnUrl" th:value="${returnUrl}"/>
|
||||
|
||||
<div class="register-form-container">
|
||||
<div class="info-notice-box">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#4685ef" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<path d="M12 16v-4"></path>
|
||||
<path d="M12 8h.01"></path>
|
||||
</svg>
|
||||
<p>내 정보 보호를 위하여 현재 비밀번호를 다시 입력해 주세요</p>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-label-wrapper">
|
||||
<span class="form-label-text">현재 비밀번호</span>
|
||||
</div>
|
||||
<div class="form-field-wrapper">
|
||||
<input type="password" name="currentPassword" class="form-input"
|
||||
placeholder="비밀번호 입력" required autofocus>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions" style="justify-content: flex-end;">
|
||||
<div class="right-buttons">
|
||||
<button type="button" class="btn-cancel" th:onclick="|location.href='@{/}'|">취소</button>
|
||||
<button type="submit" class="btn-apply btn-primary">확인</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script th:if="${error}" th:inline="javascript">
|
||||
$(document).ready(function () {
|
||||
customPopups.showAlert([[${error}]]);
|
||||
})
|
||||
</script>
|
||||
</section>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,142 @@
|
||||
<!doctype html>
|
||||
<html xmlns:th="http://www.thymeleaf.org"
|
||||
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
|
||||
layout:decorate="~{layout/djbank_base_layout}">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<title>추가 인증</title>
|
||||
</head>
|
||||
<body>
|
||||
<th:block layout:fragment="contentFragment">
|
||||
<style>
|
||||
/* Sticky footer: body 를 세로 플렉스로 만들어 콘텐츠가 남는 높이를 채우고
|
||||
푸터가 항상 뷰포트 하단에 붙도록(푸터 아래 빈 공간 제거). 이 페이지에서만 적용. */
|
||||
body { display: flex; flex-direction: column; min-height: 100vh; }
|
||||
body > .container { flex: 1 0 auto; } /* 본문 컨테이너(헤더/푸터 내부 .container 아님) */
|
||||
|
||||
/* 안내는 상단에서부터 노출(세로 중앙정렬 X → 팝업 뒤에 가려지지 않게) */
|
||||
.tfa-challenge {
|
||||
/* 상단 = 고정 헤더 높이(80px) + 여백 40px. 이 페이지는 .container 를 직접 쓰므로
|
||||
.content-wrapper .main-content 의 헤더 오프셋을 못 받아 직접 보정. */
|
||||
padding: 120px 16px 48px;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.tfa-challenge {
|
||||
padding-top: 100px; /* 모바일 헤더 최대 60px + 여백 40px */
|
||||
}
|
||||
}
|
||||
.tfa-challenge-card {
|
||||
text-align: center;
|
||||
max-width: 480px;
|
||||
margin: 0 auto; /* 가로 중앙 */
|
||||
}
|
||||
.tfa-challenge-icon {
|
||||
width: 88px;
|
||||
height: 88px;
|
||||
margin: 0 auto 24px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #E8F0FE 0%, #DCE7FB 100%);
|
||||
box-shadow: 0 8px 24px rgba(0, 73, 180, 0.15);
|
||||
}
|
||||
.tfa-challenge-icon svg { width: 44px; height: 44px; color: #0049B4; }
|
||||
.tfa-challenge-title {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #1E293B;
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
.tfa-challenge-desc {
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
color: #64748B;
|
||||
margin: 0;
|
||||
}
|
||||
.tfa-challenge-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 20px;
|
||||
padding: 6px 14px;
|
||||
border-radius: 999px;
|
||||
background: #F1F5F9;
|
||||
color: #475569;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.tfa-challenge-badge svg { width: 15px; height: 15px; }
|
||||
@media (max-width: 640px) {
|
||||
.tfa-challenge { padding: 32px 16px; }
|
||||
.tfa-challenge-icon { width: 72px; height: 72px; }
|
||||
.tfa-challenge-icon svg { width: 36px; height: 36px; }
|
||||
.tfa-challenge-title { font-size: 19px; }
|
||||
}
|
||||
</style>
|
||||
<div class="tfa-challenge">
|
||||
<div class="tfa-challenge-card">
|
||||
<div class="tfa-challenge-icon" aria-hidden="true">
|
||||
<!-- shield-check -->
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"></path>
|
||||
<path d="M9 12l2 2 4-4"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 class="tfa-challenge-title">추가 인증이 필요합니다</h2>
|
||||
<p class="tfa-challenge-desc">
|
||||
회원님의 소중한 정보를 안전하게 보호하기 위해<br>
|
||||
추가 인증을 진행해 주세요.
|
||||
</p>
|
||||
<span class="tfa-challenge-badge" aria-hidden="true">
|
||||
<!-- lock -->
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
|
||||
</svg>
|
||||
제주은행(DJBank) 보안 인증
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</th:block>
|
||||
|
||||
<th:block layout:fragment="contentScript">
|
||||
<script th:inline="javascript">
|
||||
(function () {
|
||||
var ctxRoot = /*[[@{/}]]*/ '/';
|
||||
var contextPath = ctxRoot.replace(/\/$/, '');
|
||||
var purpose = /*[[${purpose}]]*/ '';
|
||||
var returnUrl = /*[[${returnUrl}]]*/ '';
|
||||
|
||||
function goReturn() {
|
||||
window.location.href = contextPath + returnUrl;
|
||||
}
|
||||
|
||||
function open() {
|
||||
if (typeof TwoFactorAuth === 'undefined') {
|
||||
setTimeout(open, 50);
|
||||
return;
|
||||
}
|
||||
TwoFactorAuth.open({
|
||||
mode: 'stepup',
|
||||
purpose: purpose,
|
||||
onSuccess: function () { goReturn(); },
|
||||
onCancel: function () {
|
||||
// 취소/실패 → 진입 이전(직전 페이지)으로. 없으면 홈으로.
|
||||
if (document.referrer && document.referrer.indexOf(location.host) !== -1) {
|
||||
window.location.href = document.referrer;
|
||||
} else {
|
||||
window.location.href = contextPath + '/';
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', open);
|
||||
})();
|
||||
</script>
|
||||
</th:block>
|
||||
</body>
|
||||
</html>
|
||||
@@ -16,7 +16,7 @@
|
||||
<section class="service-hero">
|
||||
<div class="service-hero__inner">
|
||||
<div class="service-hero__icon-wrapper">
|
||||
<img th:src="@{/img/keyimage/notice_img.png}" alt="공지사항 아이콘" class="service-keyImg" />
|
||||
<img th:src="@{/img/keyimage/notice_img.svg}" alt="공지사항 아이콘" class="service-keyImg" />
|
||||
</div>
|
||||
<div class="service-hero__content">
|
||||
<div class="service-hero__badge">
|
||||
|
||||
@@ -260,6 +260,25 @@
|
||||
}
|
||||
});
|
||||
|
||||
// 로그인 2FA: 1차 인증 통과 후 pending 상태면 추가 인증 팝업 자동 오픈
|
||||
var twoFactorPending = [[${twoFactorPending}]];
|
||||
if (twoFactorPending && typeof TwoFactorAuth !== 'undefined') {
|
||||
TwoFactorAuth.open({
|
||||
mode: 'login',
|
||||
onSuccess: function (res) {
|
||||
window.location.href = (res && res.redirect) ? res.redirect : /*[[@{/}]]*/ '/';
|
||||
},
|
||||
onCancel: function (reason) {
|
||||
// 팝업 닫기/타임아웃/실패 = 2차 인증 실패 → 로그인 페이지 유지 후 재로그인 안내
|
||||
if (reason === 'TIMEOUT') {
|
||||
customPopups.showAlert('인증 시간이 초과되어 로그인이 취소되었습니다. 다시 로그인해주세요.');
|
||||
} else if (reason !== 'FAILED') {
|
||||
customPopups.showAlert('추가 인증이 취소되었습니다. 다시 로그인해주세요.');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fnInit();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -298,34 +298,56 @@
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
doDeleteApiKey(clientId);
|
||||
});
|
||||
}
|
||||
|
||||
$('.loading-overlay').show();
|
||||
// 인증키 삭제 요청 (step-up 필요 시 2FA 후 동일 요청 재시도)
|
||||
function doDeleteApiKey(clientId) {
|
||||
$('.loading-overlay').show();
|
||||
|
||||
const requestData = {
|
||||
clientId: clientId
|
||||
};
|
||||
|
||||
$.ajax({
|
||||
url: '/myapikey/api_key_delete',
|
||||
type: 'POST',
|
||||
contentType: 'application/json',
|
||||
data: JSON.stringify(requestData),
|
||||
headers: {
|
||||
'X-XSRF-TOKEN': /*[[${_csrf.token}]]*/ 'token'
|
||||
}
|
||||
}).done(function (response) {
|
||||
if (response && response.success === false) {
|
||||
customPopups.showAlert(response.msg || 'API 삭제에 실패했습니다.');
|
||||
return;
|
||||
}
|
||||
customPopups.showAlert(response.msg || 'API Key가 삭제되었습니다.', function () {
|
||||
window.location.href = /*[[@{/myapikey}]]*/ '/myapikey';
|
||||
});
|
||||
}).fail(function (jqXHR, textStatus, errorThrown) {
|
||||
customPopups.showAlert('API 삭제 요청 중 오류가 발생했습니다: ' + errorThrown);
|
||||
}).always(function () {
|
||||
$('.loading-overlay').hide();
|
||||
$.ajax({
|
||||
url: /*[[@{/myapikey/api_key_delete}]]*/ '/myapikey/api_key_delete',
|
||||
type: 'POST',
|
||||
contentType: 'application/json',
|
||||
data: JSON.stringify({ clientId: clientId }),
|
||||
headers: {
|
||||
'X-XSRF-TOKEN': /*[[${_csrf.token}]]*/ 'token'
|
||||
}
|
||||
}).done(function (response) {
|
||||
if (response && response.success === false) {
|
||||
customPopups.showAlert(response.msg || 'API 삭제에 실패했습니다.');
|
||||
return;
|
||||
}
|
||||
customPopups.showAlert(response.msg || 'API Key가 삭제되었습니다.', function () {
|
||||
window.location.href = /*[[@{/myapikey}]]*/ '/myapikey';
|
||||
});
|
||||
}).fail(function (jqXHR, textStatus, errorThrown) {
|
||||
if (isStepUpRequired(jqXHR)) {
|
||||
requireStepUp('/myapikey/api_key_delete', function () { doDeleteApiKey(clientId); });
|
||||
return;
|
||||
}
|
||||
customPopups.showAlert('API 삭제 요청 중 오류가 발생했습니다: ' + errorThrown);
|
||||
}).always(function () {
|
||||
$('.loading-overlay').hide();
|
||||
});
|
||||
}
|
||||
|
||||
// step-up 2FA 응답(401 + stepUpRequired) 판별 및 처리 헬퍼
|
||||
function isStepUpRequired(jqXHR) {
|
||||
return jqXHR && jqXHR.status === 401 && jqXHR.responseJSON && jqXHR.responseJSON.stepUpRequired;
|
||||
}
|
||||
|
||||
function requireStepUp(purpose, retry) {
|
||||
if (typeof TwoFactorAuth === 'undefined') {
|
||||
customPopups.showAlert('추가 인증이 필요합니다. 다시 시도해주세요.');
|
||||
return;
|
||||
}
|
||||
TwoFactorAuth.open({
|
||||
mode: 'stepup',
|
||||
purpose: purpose,
|
||||
onSuccess: function () { retry(); },
|
||||
onCancel: function () { /* 취소: 원 작업 중단 */ }
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<a class="nav-link" th:href="@{/my_company_info}">기업 정보 변경</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link active" th:href="@{/password/verify}">비밀번호 변경</a>
|
||||
<a class="nav-link active" th:href="@{/password/change}">비밀번호 변경</a>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="card mt-2">
|
||||
|
||||
@@ -79,6 +79,35 @@
|
||||
customPopups.showAlert([[${error}]]);
|
||||
})
|
||||
</script>
|
||||
<script th:inline="javascript">
|
||||
// 반영 직전 2FA: twofaRequired 면 제출을 가로채 2FA 팝업 → 성공 시 실제 제출.
|
||||
(function () {
|
||||
var twofaRequired = /*[[${twofaRequired}]]*/ false;
|
||||
if (!twofaRequired) return;
|
||||
var form = document.getElementById('passwordChangeForm');
|
||||
if (!form) return;
|
||||
|
||||
form.addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
|
||||
var np = document.getElementById('newPassword').value;
|
||||
var cp = form.querySelector('input[name="confirmPassword"]').value;
|
||||
if (!np || !cp) { customPopups.showAlert('새 비밀번호를 입력해 주세요.'); return; }
|
||||
if (np !== cp) { customPopups.showAlert('새 비밀번호가 일치하지 않습니다.'); return; }
|
||||
if (window.PasswordPolicy && !PasswordPolicy.isValid(np)) {
|
||||
customPopups.showAlert('비밀번호 규칙을 확인해 주세요.'); return;
|
||||
}
|
||||
|
||||
if (typeof TwoFactorAuth === 'undefined') { form.submit(); return; }
|
||||
TwoFactorAuth.open({
|
||||
purpose: '/password/change',
|
||||
// form.submit() 은 submit 이벤트를 재발생시키지 않으므로 그대로 서버로 전송된다.
|
||||
onSuccess: function () { form.submit(); },
|
||||
onCancel: function () { /* 사용자 취소 — 유지 */ }
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</section>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -35,6 +35,29 @@
|
||||
<div id="email-validation" class="org-validation-message"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 이메일 인증 (중복체크 통과 후 노출) -->
|
||||
<div class="org-form-group" id="emailVerifyRow" style="display: none;">
|
||||
<label class="org-form-label">
|
||||
이메일 인증 <span class="required-badge">필수</span>
|
||||
</label>
|
||||
<div class="org-form-input-wrapper">
|
||||
<div class="org-input-row">
|
||||
<button type="button" class="btn org-btn-check" id="btnSendEmailCode">인증번호 발송</button>
|
||||
</div>
|
||||
<div class="org-input-row" id="emailCodeRow" style="display: none; margin-top: 8px;">
|
||||
<div class="org-compound-input" style="position: relative; flex: 1;">
|
||||
<input type="text" id="emailAuthCode" class="org-form-input" maxlength="6"
|
||||
inputmode="numeric" autocomplete="one-time-code" placeholder="인증번호 6자리">
|
||||
<span class="org-timer" id="emailCertifyTime"
|
||||
style="position: absolute; right: 12px; top: 50%; transform: translateY(-50%); color: #E11D48;">03:00</span>
|
||||
</div>
|
||||
<button type="button" class="btn org-btn-check" id="btnVerifyEmailCode">인증확인</button>
|
||||
</div>
|
||||
<input type="hidden" id="emailVerified" name="emailVerified" value="false"/>
|
||||
<div id="email-auth-validation" class="org-validation-message"></div>
|
||||
</div>
|
||||
</div>
|
||||
</th:block>
|
||||
</body>
|
||||
<th:block>
|
||||
@@ -79,6 +102,9 @@
|
||||
newUserForm.show();
|
||||
individualConversionForm.hide();
|
||||
emailChangeForm.hide();
|
||||
|
||||
// 중복체크 통과 → 이메일 인증 UI 노출
|
||||
$('#emailVerifyRow').show();
|
||||
break;
|
||||
|
||||
case "conversionOrChange":
|
||||
@@ -162,6 +188,98 @@
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ===== 이메일 인증 (가입 폼 인라인) =====
|
||||
var emailCodeTimer = null;
|
||||
|
||||
function setEmailAuthMsg(msg, isError) {
|
||||
var el = $('#email-auth-validation');
|
||||
el.text(msg || '');
|
||||
el.css('color', isError ? '#E11D48' : '#0049B4');
|
||||
}
|
||||
|
||||
function startEmailCodeTimer() {
|
||||
clearInterval(emailCodeTimer);
|
||||
var remaining = 180;
|
||||
var el = document.getElementById('emailCertifyTime');
|
||||
$(el).show();
|
||||
function tick() {
|
||||
if (remaining <= 0) {
|
||||
clearInterval(emailCodeTimer);
|
||||
el.textContent = '00:00';
|
||||
setEmailAuthMsg('입력 시간이 초과되었습니다. 인증번호를 다시 발송해주세요.', true);
|
||||
$('#btnSendEmailCode').text('인증번호 재발송').prop('disabled', false);
|
||||
return;
|
||||
}
|
||||
var m = Math.floor(remaining / 60);
|
||||
var s = remaining % 60;
|
||||
el.textContent = (m < 10 ? '0' + m : m) + ':' + (s < 10 ? '0' + s : s);
|
||||
remaining--;
|
||||
}
|
||||
tick();
|
||||
emailCodeTimer = setInterval(tick, 1000);
|
||||
}
|
||||
|
||||
$('#btnSendEmailCode').on('click', function () {
|
||||
var email = $('#loginId').val();
|
||||
if (!email || email.indexOf('@') === -1) {
|
||||
setEmailAuthMsg('이메일을 먼저 확인해주세요.', true);
|
||||
return;
|
||||
}
|
||||
var btn = $(this);
|
||||
btn.prop('disabled', true);
|
||||
$.ajax({
|
||||
url: /*[[@{/signup/email-code/send}]]*/ '/signup/email-code/send',
|
||||
type: 'POST',
|
||||
data: { email: email, _csrf: $('input[name="_csrf"]').val() },
|
||||
success: function (res) {
|
||||
btn.prop('disabled', false);
|
||||
if (res && res.valid) {
|
||||
$('#emailCodeRow').show();
|
||||
$('#emailAuthCode').val('').focus();
|
||||
setEmailAuthMsg('인증번호를 발송했습니다.', false);
|
||||
btn.text('인증번호 재발송');
|
||||
startEmailCodeTimer();
|
||||
} else {
|
||||
setEmailAuthMsg((res && res.message) || '발송에 실패했습니다.', true);
|
||||
}
|
||||
},
|
||||
error: function () {
|
||||
btn.prop('disabled', false);
|
||||
setEmailAuthMsg('발송 중 오류가 발생했습니다.', true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
$('#btnVerifyEmailCode').on('click', function () {
|
||||
var email = $('#loginId').val();
|
||||
var code = ($('#emailAuthCode').val() || '').trim();
|
||||
if (!/^[0-9]{6}$/.test(code)) {
|
||||
setEmailAuthMsg('인증번호 6자리를 입력해주세요.', true);
|
||||
return;
|
||||
}
|
||||
$.ajax({
|
||||
url: /*[[@{/signup/email-code/verify}]]*/ '/signup/email-code/verify',
|
||||
type: 'POST',
|
||||
data: { email: email, code: code, _csrf: $('input[name="_csrf"]').val() },
|
||||
success: function (res) {
|
||||
if (res && res.valid) {
|
||||
clearInterval(emailCodeTimer);
|
||||
$('#emailVerified').val('true');
|
||||
$('#emailAuthCode').prop('readonly', true);
|
||||
$('#btnVerifyEmailCode').prop('disabled', true);
|
||||
$('#btnSendEmailCode').prop('disabled', true);
|
||||
$('#emailCertifyTime').hide();
|
||||
setEmailAuthMsg('이메일 인증이 완료되었습니다.', false);
|
||||
} else {
|
||||
setEmailAuthMsg((res && res.message) || '인증번호가 일치하지 않습니다.', true);
|
||||
}
|
||||
},
|
||||
error: function () {
|
||||
setEmailAuthMsg('인증 처리 중 오류가 발생했습니다.', true);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
@@ -27,6 +27,29 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 이메일 인증 (중복체크 통과 후 노출) -->
|
||||
<div class="org-form-group" id="emailVerifyRow" style="display: none;">
|
||||
<label class="org-form-label">
|
||||
이메일 인증 <span class="required-badge">필수</span>
|
||||
</label>
|
||||
<div class="org-form-input-wrapper">
|
||||
<div class="org-input-row">
|
||||
<button type="button" class="btn org-btn-check" id="btnSendEmailCode">인증번호 발송</button>
|
||||
</div>
|
||||
<div class="org-input-row" id="emailCodeRow" style="display: none; margin-top: 8px;">
|
||||
<div class="org-compound-input" style="position: relative; flex: 1;">
|
||||
<input type="text" id="emailAuthCode" class="org-form-input" maxlength="6"
|
||||
inputmode="numeric" autocomplete="one-time-code" placeholder="인증번호 6자리">
|
||||
<span class="org-timer" id="emailCertifyTime"
|
||||
style="position: absolute; right: 12px; top: 50%; transform: translateY(-50%); color: #E11D48;">03:00</span>
|
||||
</div>
|
||||
<button type="button" class="btn org-btn-check" id="btnVerifyEmailCode">인증확인</button>
|
||||
</div>
|
||||
<input type="hidden" id="emailVerified" name="emailVerified" value="false"/>
|
||||
<div id="email-auth-validation" class="org-validation-message"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dynamic Form Container -->
|
||||
<div id="dynamicFormContainer">
|
||||
<div id="individualConversionForm" style="display: none;">
|
||||
@@ -97,6 +120,10 @@
|
||||
if (newUserForm) newUserForm.style.display = 'block';
|
||||
if (individualConversionForm) individualConversionForm.style.display = 'none';
|
||||
if (emailChangeForm) emailChangeForm.style.display = 'none';
|
||||
|
||||
// 중복체크 통과 → 이메일 인증 UI 노출
|
||||
var evRow = document.getElementById('emailVerifyRow');
|
||||
if (evRow) evRow.style.display = 'block';
|
||||
break;
|
||||
|
||||
case "conversionOrChange":
|
||||
@@ -212,6 +239,108 @@
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 이메일 인증 (법인 가입 폼 인라인) =====
|
||||
var emailCodeTimer = null;
|
||||
|
||||
function setEmailAuthMsg(msg, isError) {
|
||||
var el = document.getElementById('email-auth-validation');
|
||||
if (!el) return;
|
||||
el.textContent = msg || '';
|
||||
el.style.color = isError ? '#E11D48' : '#0049B4';
|
||||
}
|
||||
|
||||
function startEmailCodeTimer() {
|
||||
clearInterval(emailCodeTimer);
|
||||
var remaining = 180;
|
||||
var el = document.getElementById('emailCertifyTime');
|
||||
if (el) el.style.display = '';
|
||||
function tick() {
|
||||
if (remaining <= 0) {
|
||||
clearInterval(emailCodeTimer);
|
||||
if (el) el.textContent = '00:00';
|
||||
setEmailAuthMsg('입력 시간이 초과되었습니다. 인증번호를 다시 발송해주세요.', true);
|
||||
var sb = document.getElementById('btnSendEmailCode');
|
||||
if (sb) { sb.textContent = '인증번호 재발송'; sb.disabled = false; }
|
||||
return;
|
||||
}
|
||||
var m = Math.floor(remaining / 60);
|
||||
var s = remaining % 60;
|
||||
if (el) el.textContent = (m < 10 ? '0' + m : m) + ':' + (s < 10 ? '0' + s : s);
|
||||
remaining--;
|
||||
}
|
||||
tick();
|
||||
emailCodeTimer = setInterval(tick, 1000);
|
||||
}
|
||||
|
||||
var sendBtn = document.getElementById('btnSendEmailCode');
|
||||
if (sendBtn) {
|
||||
sendBtn.addEventListener('click', function () {
|
||||
var email = document.getElementById('loginId').value;
|
||||
if (!email || email.indexOf('@') === -1) {
|
||||
setEmailAuthMsg('이메일을 먼저 확인해주세요.', true);
|
||||
return;
|
||||
}
|
||||
var btn = this;
|
||||
btn.disabled = true;
|
||||
$.ajax({
|
||||
url: '/signup/email-code/send',
|
||||
type: 'POST',
|
||||
data: { email: email, _csrf: document.querySelector('input[name="_csrf"]')?.value },
|
||||
success: function (res) {
|
||||
btn.disabled = false;
|
||||
if (res && res.valid) {
|
||||
document.getElementById('emailCodeRow').style.display = '';
|
||||
var ci = document.getElementById('emailAuthCode');
|
||||
ci.value = ''; ci.focus();
|
||||
setEmailAuthMsg('인증번호를 발송했습니다.', false);
|
||||
btn.textContent = '인증번호 재발송';
|
||||
startEmailCodeTimer();
|
||||
} else {
|
||||
setEmailAuthMsg((res && res.message) || '발송에 실패했습니다.', true);
|
||||
}
|
||||
},
|
||||
error: function () {
|
||||
btn.disabled = false;
|
||||
setEmailAuthMsg('발송 중 오류가 발생했습니다.', true);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
var verifyBtn = document.getElementById('btnVerifyEmailCode');
|
||||
if (verifyBtn) {
|
||||
verifyBtn.addEventListener('click', function () {
|
||||
var email = document.getElementById('loginId').value;
|
||||
var code = (document.getElementById('emailAuthCode').value || '').trim();
|
||||
if (!/^[0-9]{6}$/.test(code)) {
|
||||
setEmailAuthMsg('인증번호 6자리를 입력해주세요.', true);
|
||||
return;
|
||||
}
|
||||
$.ajax({
|
||||
url: '/signup/email-code/verify',
|
||||
type: 'POST',
|
||||
data: { email: email, code: code, _csrf: document.querySelector('input[name="_csrf"]')?.value },
|
||||
success: function (res) {
|
||||
if (res && res.valid) {
|
||||
clearInterval(emailCodeTimer);
|
||||
document.getElementById('emailVerified').value = 'true';
|
||||
document.getElementById('emailAuthCode').readOnly = true;
|
||||
verifyBtn.disabled = true;
|
||||
document.getElementById('btnSendEmailCode').disabled = true;
|
||||
var t = document.getElementById('emailCertifyTime');
|
||||
if (t) t.style.display = 'none';
|
||||
setEmailAuthMsg('이메일 인증이 완료되었습니다.', false);
|
||||
} else {
|
||||
setEmailAuthMsg((res && res.message) || '인증번호가 일치하지 않습니다.', true);
|
||||
}
|
||||
},
|
||||
error: function () {
|
||||
setEmailAuthMsg('인증 처리 중 오류가 발생했습니다.', true);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
@@ -3,16 +3,6 @@
|
||||
xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
|
||||
<body>
|
||||
<th:block th:fragment="headerFragment(headerClass)">
|
||||
<!-- Design Survey Bar -->
|
||||
<div th:if="${designSurveyEnabled}" class="design-survey-bar" id="designSurveyBar">
|
||||
<div class="survey-container">
|
||||
<span class="survey-label">네비게이션 디자인을 선택해주세요:</span>
|
||||
<div class="survey-buttons" id="surveyButtons">
|
||||
<!-- 버튼은 JavaScript에서 동적으로 생성됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Global Header Container -->
|
||||
<header class="global-header" th:classappend="${headerClass}">
|
||||
<div class="container">
|
||||
@@ -97,7 +87,7 @@
|
||||
<a th:href="@{/statistics/api}"><i class="fas fa-chart-bar"></i>이용 통계</a>
|
||||
</li>
|
||||
<li><a th:href="@{/mypage}"><i class="fas fa-user-circle"></i>내 정보 관리</a></li>
|
||||
<li><a th:href="@{/password/verify}"><i class="fas fa-lock"></i>비밀번호 변경</a></li>
|
||||
<li><a th:href="@{/password/change}"><i class="fas fa-lock"></i>비밀번호 변경</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@@ -253,7 +243,7 @@
|
||||
<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>
|
||||
<li><a th:href="@{/password/verify}">비밀번호 변경</a></li>
|
||||
<li><a th:href="@{/password/change}">비밀번호 변경</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -579,107 +569,6 @@
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Design Survey Configuration
|
||||
// 새 디자인 옵션 추가: DESIGN_OPTIONS 배열에 항목 추가
|
||||
// 예: { id: 'D', label: 'D', styles: { '.logo-text': { 'font-size': '16px' } } }
|
||||
// ============================================================
|
||||
const DESIGN_OPTIONS = [
|
||||
{
|
||||
id: 'A',
|
||||
label: 'A (현재)',
|
||||
isDefault: true,
|
||||
styles: {} // 기본 스타일 (변경 없음)
|
||||
},
|
||||
{
|
||||
id: 'B',
|
||||
label: 'B',
|
||||
styles: {
|
||||
'.logo-text': { 'font-size': '18px' },
|
||||
'.nav-link': { 'margin': '0 20px' }
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'C',
|
||||
label: 'C',
|
||||
styles: {
|
||||
'.nav-link': { 'margin': '0 20px', 'font-weight': 'bold' }
|
||||
}
|
||||
}
|
||||
// 새 디자인 추가 예시:
|
||||
// {
|
||||
// id: 'D',
|
||||
// label: 'D',
|
||||
// styles: {
|
||||
// '.logo-text': { 'font-size': '16px', 'color': '#333' },
|
||||
// '.nav-link': { 'padding': '10px 24px' }
|
||||
// }
|
||||
// }
|
||||
];
|
||||
|
||||
// Design Survey Logic
|
||||
const surveyBar = document.getElementById('designSurveyBar');
|
||||
if (surveyBar) {
|
||||
const body = document.body;
|
||||
const buttonContainer = document.getElementById('surveyButtons');
|
||||
const STORAGE_KEY = 'design-survey-selection';
|
||||
let styleElement = null;
|
||||
|
||||
body.classList.add('design-survey-active');
|
||||
|
||||
// 버튼 동적 생성
|
||||
DESIGN_OPTIONS.forEach(option => {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'survey-btn';
|
||||
btn.dataset.design = option.id;
|
||||
btn.textContent = option.label;
|
||||
buttonContainer.appendChild(btn);
|
||||
});
|
||||
|
||||
const surveyButtons = buttonContainer.querySelectorAll('.survey-btn');
|
||||
const defaultOption = DESIGN_OPTIONS.find(o => o.isDefault) || DESIGN_OPTIONS[0];
|
||||
const savedDesign = localStorage.getItem(STORAGE_KEY) || defaultOption.id;
|
||||
|
||||
applyDesign(savedDesign);
|
||||
surveyButtons.forEach(btn => {
|
||||
btn.classList.toggle('active', btn.dataset.design === savedDesign);
|
||||
});
|
||||
|
||||
surveyButtons.forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
surveyButtons.forEach(b => b.classList.remove('active'));
|
||||
this.classList.add('active');
|
||||
applyDesign(this.dataset.design);
|
||||
localStorage.setItem(STORAGE_KEY, this.dataset.design);
|
||||
});
|
||||
});
|
||||
|
||||
function applyDesign(designId) {
|
||||
// 기존 동적 스타일 제거
|
||||
if (styleElement) {
|
||||
styleElement.remove();
|
||||
styleElement = null;
|
||||
}
|
||||
|
||||
const option = DESIGN_OPTIONS.find(o => o.id === designId);
|
||||
if (!option || Object.keys(option.styles).length === 0) return;
|
||||
|
||||
// 동적 스타일 생성
|
||||
let css = '';
|
||||
for (const [selector, props] of Object.entries(option.styles)) {
|
||||
const propsStr = Object.entries(props)
|
||||
.map(([prop, val]) => `${prop}: ${val} !important`)
|
||||
.join('; ');
|
||||
css += `${selector} { ${propsStr}; }\n`;
|
||||
}
|
||||
|
||||
styleElement = document.createElement('style');
|
||||
styleElement.id = 'design-survey-styles';
|
||||
styleElement.textContent = css;
|
||||
document.head.appendChild(styleElement);
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</th:block>
|
||||
|
||||
@@ -73,7 +73,7 @@
|
||||
th:classappend="${activeMenu == 'profile'} ? 'service-nav__item--active' : ''"
|
||||
class="service-nav__item">내정보 관리</a>
|
||||
|
||||
<a th:href="@{/password/verify}"
|
||||
<a th:href="@{/password/change}"
|
||||
th:classappend="${activeMenu == 'password'} ? 'service-nav__item--active' : ''"
|
||||
class="service-nav__item">비밀번호 변경</a>
|
||||
</th:block>
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
<!-- views/fragment/popup/twoFactorAuthPopup.html : 공통 2FA(추가 인증) 팝업 -->
|
||||
<div th:fragment="twoFactorAuthPopup" id="tfaPopup" style="display: none;" xmlns:th="http://www.thymeleaf.org">
|
||||
<div class="modal-backdrop" id="tfaBackdrop"></div>
|
||||
|
||||
<div class="modal" id="tfaModal">
|
||||
<div class="modal-dialog tfa-card">
|
||||
|
||||
<button type="button" class="tfa-close" id="tfaCloseButton" aria-label="닫기">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- 헤더 -->
|
||||
<div class="tfa-head">
|
||||
<span class="tfa-head-title" id="tfaTitle">추가 인증</span>
|
||||
<span class="tfa-head-brand">DJBank</span>
|
||||
</div>
|
||||
|
||||
<div class="tfa-body">
|
||||
|
||||
<!-- STEP 1 : 인증 수단 -->
|
||||
<div class="tfa-step">
|
||||
<span class="tfa-step-num is-active">1</span>
|
||||
<span class="tfa-step-text is-active">인증 수단</span>
|
||||
</div>
|
||||
<!-- 채널 선택(이메일/휴대폰) — JS 가 렌더링 -->
|
||||
<div class="tfa-segment" id="tfaSegment" role="tablist"></div>
|
||||
|
||||
<!-- STEP 2 : 인증번호 입력 -->
|
||||
<div class="tfa-step">
|
||||
<span class="tfa-step-num" id="tfaStepNum">2</span>
|
||||
<span class="tfa-step-text" id="tfaStepText">인증번호 입력</span>
|
||||
</div>
|
||||
|
||||
<!-- 인증번호 발송 전 -->
|
||||
<div id="tfaSendStep">
|
||||
<div class="tfa-hint">아래 버튼을 누르면 선택하신 수단으로 6자리 인증번호를 보내드립니다.</div>
|
||||
<button type="button" class="tfa-cta" id="tfaSendButton">인증번호 받기</button>
|
||||
</div>
|
||||
|
||||
<!-- 인증번호 발송 후 -->
|
||||
<div id="tfaVerifyStep" style="display: none;">
|
||||
<div class="tfa-code-row">
|
||||
<div class="tfa-code-input-wrap">
|
||||
<input type="text" id="tfaCodeInput" class="tfa-code-input" inputmode="numeric" maxlength="6"
|
||||
autocomplete="one-time-code" placeholder="인증번호 6자리"/>
|
||||
</div>
|
||||
<button type="button" class="tfa-resend" id="tfaResendButton">재전송</button>
|
||||
</div>
|
||||
|
||||
<div class="tfa-timer-row">
|
||||
<span class="tfa-timer-label" id="tfaTimerLabel">남은 인증 시간</span>
|
||||
<span class="tfa-timer-clock">
|
||||
<span class="tfa-timer" id="tfaTimer">3:00</span>
|
||||
<span class="tfa-timer-total" id="tfaTimerTotal">/ 3:00</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="tfa-progress"><div class="tfa-progress-bar" id="tfaProgressBar"></div></div>
|
||||
|
||||
<p class="tfa-message" id="tfaMessage"></p>
|
||||
<div class="tfa-test-notice" id="tfaTestNotice" style="display: none;"></div>
|
||||
|
||||
<button type="button" class="tfa-cta" id="tfaVerifyButton" disabled>인증 완료</button>
|
||||
</div>
|
||||
|
||||
<div class="tfa-foot">인증번호가 오지 않으면 스팸함을 확인하시거나 다른 수단으로 다시 시도해 주세요.</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -22,7 +22,9 @@
|
||||
|
||||
<footer th:replace="fragment/djbank/footer :: footerFragment"></footer>
|
||||
<section th:replace="fragment/popup/customPopups :: customPopups"></section>
|
||||
<section th:replace="fragment/popup/twoFactorAuthPopup :: twoFactorAuthPopup"></section>
|
||||
<section layout:fragment="pagePopups"></section>
|
||||
<script th:src="@{/js/popup/custom-popups.js}"></script>
|
||||
<script th:src="@{/js/popup/two-factor-auth.js}"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -23,7 +23,9 @@
|
||||
|
||||
<footer th:replace="fragment/djbank/footer :: footerFragment"></footer>
|
||||
<section th:replace="fragment/popup/customPopups :: customPopups"></section>
|
||||
<section th:replace="fragment/popup/twoFactorAuthPopup :: twoFactorAuthPopup"></section>
|
||||
<section layout:fragment="pagePopups"></section>
|
||||
<script th:src="@{/js/popup/custom-popups.js}"></script>
|
||||
<script th:src="@{/js/popup/two-factor-auth.js}"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user