merge 충돌 해결

This commit is contained in:
hong
2026-07-28 14:35:21 +09:00
59 changed files with 3651 additions and 666 deletions
+3
View File
@@ -316,6 +316,9 @@ page:
api_detail:
name: "API 상세보기"
path: "/apis/detail"
api_testbed:
name: "테스트베드"
path: "/apis/detail/testbed"
community:
name: 고객지원
path: "#"
+362 -135
View File
@@ -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
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;
+1 -1
View File
@@ -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>