feats/security → design 병합: 2FA·Step-up 인증 통합

- SuccessHandler 충돌 해결: LoginFinalizer 리팩터 버전 채택
- LoginFinalizer 로그 마스킹 재적용(design 변경 보존)
- CSS 생성물 충돌은 SASS 재컴파일로 해소(.tfa- 포함)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Rinjae
2026-07-28 09:55:57 +09:00
43 changed files with 3174 additions and 29073 deletions
+324 -69
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;
@@ -7634,6 +7565,330 @@ button.djb-comment-submit:disabled {
color: #888;
}
.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%;
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
View File
@@ -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>
@@ -248,6 +248,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,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>