2FA (추가 인증) 기능 추가:
- Step-up 인증 Interceptor 및 보호 경로 관리 로직 도입 - 2FA 팝업 모듈 및 스타일(SASS, JS) 추가 - 로그인 실패 사유 Enum 및 2FA 관련 처리 로직 공통화
This commit is contained in:
@@ -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;
|
||||
@@ -7634,6 +7565,123 @@ button.djb-comment-submit:disabled {
|
||||
color: #888;
|
||||
}
|
||||
|
||||
#tfaModal .modal-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tfa-subtitle {
|
||||
text-align: center;
|
||||
color: #64748B;
|
||||
margin-bottom: 24px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.tfa-segment {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.tfa-segment .tfa-seg-btn {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 12px 8px;
|
||||
border: 1px solid #CBD5E1;
|
||||
border-radius: 8px;
|
||||
background: #F8FAFC;
|
||||
color: #475569;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
.tfa-segment .tfa-seg-btn:hover {
|
||||
border-color: #0049B4;
|
||||
}
|
||||
.tfa-segment .tfa-seg-btn.is-active {
|
||||
background: #0049B4;
|
||||
border-color: #0049B4;
|
||||
color: #FFFFFF;
|
||||
}
|
||||
.tfa-segment .tfa-seg-btn.is-active .tfa-seg-masked {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
.tfa-segment .tfa-seg-masked {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: #94A3B8;
|
||||
}
|
||||
|
||||
.tfa-btn-block {
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.tfa-code-row {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.tfa-code-row .tfa-code-input {
|
||||
width: 100%;
|
||||
padding: 14px 64px 14px 16px;
|
||||
border: 1px solid #CBD5E1;
|
||||
border-radius: 8px;
|
||||
font-size: 15px;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
.tfa-code-row .tfa-code-input:focus {
|
||||
outline: none;
|
||||
border-color: #0049B4;
|
||||
}
|
||||
.tfa-code-row .tfa-timer {
|
||||
position: absolute;
|
||||
right: 16px;
|
||||
color: #E11D48;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.tfa-message {
|
||||
min-height: 18px;
|
||||
margin-top: 8px;
|
||||
font-size: 13px;
|
||||
color: #64748B;
|
||||
}
|
||||
.tfa-message.is-error {
|
||||
color: #E11D48;
|
||||
}
|
||||
|
||||
.tfa-test-notice {
|
||||
margin-top: 8px;
|
||||
padding: 10px 14px;
|
||||
border: 1px dashed #F59E0B;
|
||||
border-radius: 8px;
|
||||
background: #FFFBEB;
|
||||
color: #B45309;
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.tfa-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin: 8px 0;
|
||||
}
|
||||
.tfa-actions .tfa-resend {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #0049B4;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.hero-carousel-section {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
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,296 @@
|
||||
/*
|
||||
* 공통 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 = '';
|
||||
var channels = info.channels || [];
|
||||
channels.forEach(function (ch, idx) {
|
||||
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 = (ch.type === 'EMAIL' ? '이메일 인증' : '전화번호 인증')
|
||||
+ '<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';
|
||||
document.getElementById('tfaMessage').textContent = '';
|
||||
document.getElementById('tfaCodeInput').value = '';
|
||||
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').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._setMessage('', false);
|
||||
document.getElementById('tfaCodeInput').value = '';
|
||||
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);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
_startTimer: function (seconds) {
|
||||
var self = this;
|
||||
self._stopTimer();
|
||||
var remaining = seconds;
|
||||
var el = document.getElementById('tfaTimer');
|
||||
function tick() {
|
||||
if (remaining <= 0) {
|
||||
self._stopTimer();
|
||||
el.textContent = '00:00';
|
||||
self._cancel('TIMEOUT');
|
||||
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();
|
||||
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,134 @@
|
||||
@use '../abstracts/variables' as *;
|
||||
@use '../abstracts/color-functions' as *;
|
||||
@use '../abstracts/mixins' as *;
|
||||
|
||||
// 공통 2FA(추가 인증) 팝업. .modal / .modal-backdrop / .modal-dialog 는 _modals 재사용.
|
||||
|
||||
#tfaModal {
|
||||
.modal-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.tfa-subtitle {
|
||||
text-align: center;
|
||||
color: #64748B;
|
||||
margin-bottom: 24px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.tfa-segment {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 20px;
|
||||
|
||||
.tfa-seg-btn {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 12px 8px;
|
||||
border: 1px solid #CBD5E1;
|
||||
border-radius: 8px;
|
||||
background: #F8FAFC;
|
||||
color: #475569;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: $primary-color;
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
background: $primary-color;
|
||||
border-color: $primary-color;
|
||||
color: #FFFFFF;
|
||||
|
||||
.tfa-seg-masked {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tfa-seg-masked {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: #94A3B8;
|
||||
}
|
||||
}
|
||||
|
||||
.tfa-btn-block {
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.tfa-code-row {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.tfa-code-input {
|
||||
width: 100%;
|
||||
padding: 14px 64px 14px 16px;
|
||||
border: 1px solid #CBD5E1;
|
||||
border-radius: 8px;
|
||||
font-size: 15px;
|
||||
letter-spacing: 2px;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: $primary-color;
|
||||
}
|
||||
}
|
||||
|
||||
.tfa-timer {
|
||||
position: absolute;
|
||||
right: 16px;
|
||||
color: #E11D48;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
}
|
||||
|
||||
.tfa-message {
|
||||
min-height: 18px;
|
||||
margin-top: 8px;
|
||||
font-size: 13px;
|
||||
color: #64748B;
|
||||
|
||||
&.is-error {
|
||||
color: #E11D48;
|
||||
}
|
||||
}
|
||||
|
||||
.tfa-test-notice {
|
||||
margin-top: 8px;
|
||||
padding: 10px 14px;
|
||||
border: 1px dashed #F59E0B;
|
||||
border-radius: 8px;
|
||||
background: #FFFBEB;
|
||||
color: #B45309;
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.tfa-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin: 8px 0;
|
||||
|
||||
.tfa-resend {
|
||||
background: none;
|
||||
border: none;
|
||||
color: $primary-color;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
@use 'components/test-env-notice' as *;
|
||||
@use 'components/djb-inquiry-comments' as *;
|
||||
@use 'components/password-policy' as *;
|
||||
@use 'components/two-factor' as *;
|
||||
|
||||
// 5. Page-specific styles
|
||||
@use 'pages/index' as *;
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
<!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 {
|
||||
padding: 40px 16px 48px; /* 상단 약간의 여백만 */
|
||||
}
|
||||
.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>
|
||||
@@ -244,6 +244,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>
|
||||
|
||||
@@ -197,39 +197,30 @@
|
||||
);
|
||||
}
|
||||
|
||||
// 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() { /* 취소: 원 작업 중단 */ }
|
||||
});
|
||||
}
|
||||
|
||||
// Show password prompt for viewing client secret (최초 1회 노출 + 서버측 물리 삭제)
|
||||
function showPasswordPrompt() {
|
||||
customPopups.showPasswordInput({
|
||||
title: 'Client Secret 조회',
|
||||
message: '보안을 위해 비밀번호를 입력해주세요.<br>조회 즉시 값은 영구 삭제됩니다.',
|
||||
onConfirm: function(password) {
|
||||
$.ajax({
|
||||
url: /*[[@{/myapikey/credential/reveal-secret}]]*/ '/myapikey/credential/reveal-secret',
|
||||
type: 'POST',
|
||||
contentType: 'application/json',
|
||||
data: JSON.stringify({ clientId: CREDENTIAL_CLIENT_ID, password: password }),
|
||||
headers: {
|
||||
'X-XSRF-TOKEN': /*[[${_csrf.token}]]*/ 'token'
|
||||
}
|
||||
}).done(function(response) {
|
||||
if (response.success) {
|
||||
customPopups.hidePasswordInput();
|
||||
|
||||
// 서버가 반환한 secret을 화면에 1회 주입
|
||||
$('#revealedSecretValue').text(response.secret);
|
||||
$('#revealedSecretCopyBtn').attr('data-secret', response.secret).data('secret', response.secret);
|
||||
|
||||
$('#hiddenSecretBox').hide();
|
||||
$('#revealedSecretBox').fadeIn(300);
|
||||
} else if (response.alreadyRevealed) {
|
||||
customPopups.hidePasswordInput();
|
||||
showLostKeyGuide();
|
||||
} else {
|
||||
customPopups.showPasswordError(response.message || '비밀번호가 일치하지 않습니다.');
|
||||
}
|
||||
}).fail(function() {
|
||||
customPopups.showPasswordError('오류가 발생했습니다. 다시 시도해주세요.');
|
||||
});
|
||||
doRevealSecret(password);
|
||||
},
|
||||
onCancel: function() {
|
||||
// User cancelled - do nothing
|
||||
@@ -237,6 +228,42 @@
|
||||
});
|
||||
}
|
||||
|
||||
// Client Secret 조회 요청 (step-up 필요 시 2FA 후 동일 요청 재시도)
|
||||
function doRevealSecret(password) {
|
||||
$.ajax({
|
||||
url: /*[[@{/myapikey/credential/reveal-secret}]]*/ '/myapikey/credential/reveal-secret',
|
||||
type: 'POST',
|
||||
contentType: 'application/json',
|
||||
data: JSON.stringify({ clientId: CREDENTIAL_CLIENT_ID, password: password }),
|
||||
headers: {
|
||||
'X-XSRF-TOKEN': /*[[${_csrf.token}]]*/ 'token'
|
||||
}
|
||||
}).done(function(response) {
|
||||
if (response.success) {
|
||||
customPopups.hidePasswordInput();
|
||||
|
||||
// 서버가 반환한 secret을 화면에 1회 주입
|
||||
$('#revealedSecretValue').text(response.secret);
|
||||
$('#revealedSecretCopyBtn').attr('data-secret', response.secret).data('secret', response.secret);
|
||||
|
||||
$('#hiddenSecretBox').hide();
|
||||
$('#revealedSecretBox').fadeIn(300);
|
||||
} else if (response.alreadyRevealed) {
|
||||
customPopups.hidePasswordInput();
|
||||
showLostKeyGuide();
|
||||
} else {
|
||||
customPopups.showPasswordError(response.message || '비밀번호가 일치하지 않습니다.');
|
||||
}
|
||||
}).fail(function(jqXHR) {
|
||||
if (isStepUpRequired(jqXHR)) {
|
||||
customPopups.hidePasswordInput();
|
||||
requireStepUp('/myapikey/credential/reveal-secret', function() { doRevealSecret(password); });
|
||||
return;
|
||||
}
|
||||
customPopups.showPasswordError('오류가 발생했습니다. 다시 시도해주세요.');
|
||||
});
|
||||
}
|
||||
|
||||
// Copy to clipboard function - called from button with data-secret attribute
|
||||
function copyToClipboardFromButton(button) {
|
||||
var text = $(button).data('secret');
|
||||
@@ -277,34 +304,38 @@
|
||||
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',
|
||||
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();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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">
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
<!-- 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">
|
||||
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title" id="tfaTitle">
|
||||
<span aria-hidden="true">🔒</span> 추가 인증
|
||||
</h3>
|
||||
<button type="button" class="modal-close" id="tfaCloseButton" aria-label="닫기">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" 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>
|
||||
|
||||
<div class="modal-body">
|
||||
<p class="tfa-subtitle">제주은행(DJBank)에 오신걸 환영합니다</p>
|
||||
|
||||
<!-- 채널 선택 세그먼트 (이메일/전화번호) -->
|
||||
<div class="tfa-segment" id="tfaSegment" role="tablist">
|
||||
<!-- JS 가 채널 버튼을 렌더링 -->
|
||||
</div>
|
||||
|
||||
<!-- 인증번호 발송 전 -->
|
||||
<div id="tfaSendStep">
|
||||
<button type="button" class="btn btn-primary tfa-btn-block" id="tfaSendButton">인증번호 발송</button>
|
||||
</div>
|
||||
|
||||
<!-- 인증번호 발송 후 -->
|
||||
<div id="tfaVerifyStep" style="display: none;">
|
||||
<div class="tfa-code-row">
|
||||
<input type="text" id="tfaCodeInput" class="tfa-code-input" inputmode="numeric" maxlength="6"
|
||||
autocomplete="one-time-code" placeholder="인증번호를 입력해주세요"/>
|
||||
<span class="tfa-timer" id="tfaTimer">03:00</span>
|
||||
</div>
|
||||
<div class="tfa-message" id="tfaMessage"></div>
|
||||
<div class="tfa-test-notice" id="tfaTestNotice" style="display: none;"></div>
|
||||
<div class="tfa-actions">
|
||||
<button type="button" class="tfa-resend" id="tfaResendButton">인증번호 재발송</button>
|
||||
</div>
|
||||
<button type="button" class="btn btn-primary tfa-btn-block" id="tfaVerifyButton">인증코드 확인</button>
|
||||
</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