686 lines
29 KiB
JavaScript
686 lines
29 KiB
JavaScript
// biome-ignore-all lint: legacy ES5 controller moved from inline JSX so soft navigation can execute it safely
|
||
(function(){
|
||
function modal(){ return document.getElementById('auth-modal'); }
|
||
function open(tab){
|
||
var m = modal(); if (!m) return;
|
||
setTab(tab || 'login');
|
||
if (typeof m.showModal === 'function') { try { m.showModal(); } catch(_) { m.setAttribute('open',''); } }
|
||
else m.setAttribute('open','');
|
||
}
|
||
function close(){
|
||
var m = modal(); if (!m) return;
|
||
if (typeof m.close === 'function') m.close();
|
||
else m.removeAttribute('open');
|
||
}
|
||
function setTab(name){
|
||
var m = modal(); if (!m) return;
|
||
m.querySelectorAll('[data-auth-tab]').forEach(function(t){
|
||
var on = t.getAttribute('data-auth-tab') === name;
|
||
t.classList.toggle('active', on);
|
||
t.setAttribute('aria-selected', on ? 'true' : 'false');
|
||
});
|
||
m.querySelectorAll('[data-auth-panel]').forEach(function(p){
|
||
p.hidden = p.getAttribute('data-auth-panel') !== name;
|
||
});
|
||
}
|
||
// URL ?auth_error=... 처리 — 페이지 첫 진입 시 한 번만
|
||
if (!window.__authErrChecked) {
|
||
window.__authErrChecked = true;
|
||
try {
|
||
var u = new URL(location.href);
|
||
var err = u.searchParams.get('auth_error');
|
||
if (err) {
|
||
var initTab = (location.hash === '#signup') ? 'signup' : 'login';
|
||
setTimeout(function(){
|
||
open(initTab);
|
||
var m = modal();
|
||
var panel = m && m.querySelector('[data-auth-panel="' + initTab + '"]');
|
||
if (panel) {
|
||
var box = document.createElement('div');
|
||
box.className = 'auth-error';
|
||
box.textContent = err;
|
||
panel.insertBefore(box, panel.firstChild);
|
||
}
|
||
}, 50);
|
||
u.searchParams.delete('auth_error');
|
||
u.hash = '';
|
||
history.replaceState(null, '', u.pathname + (u.search || ''));
|
||
}
|
||
} catch(_) {}
|
||
}
|
||
// 이메일 인증 핸들러 — signup 폼 안의 send/verify 버튼 + 이메일 변경 감지
|
||
if (!window.__emverifBound) {
|
||
window.__emverifBound = true;
|
||
function emForm(){ return document.querySelector('[data-em-form]'); }
|
||
function emEl(sel){ var f = emForm(); return f ? f.querySelector(sel) : null; }
|
||
function emStatus(text, kind){
|
||
var s = emEl('[data-em-status]'); if (!s) return;
|
||
s.textContent = text || '';
|
||
s.className = 'auth-status' + (kind ? ' is-' + kind : '');
|
||
}
|
||
function emReset(){
|
||
var code = emEl('[data-em-code]');
|
||
if (code) { code.value = ''; code.disabled = true; code.placeholder = '먼저 인증코드 받기를 눌러주세요'; }
|
||
var verify = emEl('[data-em-verify]'); if (verify) verify.disabled = true;
|
||
var submit = emEl('[data-em-submit]'); if (submit) submit.disabled = true;
|
||
window.__emailVerified = false;
|
||
window.__emailVerifiedFor = '';
|
||
emStatus('', '');
|
||
}
|
||
async function emPost(url, body){
|
||
try {
|
||
var res = await fetch(url, {
|
||
method: 'POST',
|
||
headers: { 'content-type': 'application/json', 'accept': 'application/json' },
|
||
body: JSON.stringify(body),
|
||
});
|
||
var data = null;
|
||
try { data = await res.json(); } catch(_) {}
|
||
return { ok: res.ok, status: res.status, data: data || {} };
|
||
} catch (_) {
|
||
return { ok: false, status: 0, data: { error: '네트워크 오류' } };
|
||
}
|
||
}
|
||
document.addEventListener('click', async function(e){
|
||
var sendBtn = e.target.closest && e.target.closest('[data-em-send]');
|
||
if (sendBtn) {
|
||
e.preventDefault();
|
||
var emailEl = emEl('[data-em-input]');
|
||
var email = emailEl ? String(emailEl.value || '').trim().toLowerCase() : '';
|
||
if (!email) { emStatus('이메일을 입력해주세요', 'err'); return; }
|
||
// 일반 이메일 형식만 client 측에서 빠르게 검사. 허용 도메인 검증은 서버에서
|
||
// (어드민 설정 allowed_email_domains 기준) — 도메인 정책 바뀌어도 client 코드 손볼 필요 없게.
|
||
if (!/^[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}$/.test(email)) {
|
||
emStatus('이메일 형식이 올바르지 않습니다', 'err');
|
||
return;
|
||
}
|
||
if (email.split('@')[0].indexOf('+') !== -1) {
|
||
emStatus("'+' 가 포함된 이메일은 사용할 수 없습니다", 'err');
|
||
return;
|
||
}
|
||
sendBtn.disabled = true;
|
||
emStatus('전송 중...', '');
|
||
var r = await emPost('/api/auth/send-email-code', { email: email });
|
||
if (r.ok && r.data.ok) {
|
||
var codeIn = emEl('[data-em-code]');
|
||
if (codeIn) { codeIn.disabled = false; codeIn.placeholder = '6자리 숫자'; codeIn.focus(); }
|
||
var verifyB = emEl('[data-em-verify]'); if (verifyB) verifyB.disabled = false;
|
||
emStatus('인증 코드를 전송했습니다 (10분 안에 입력)', 'ok');
|
||
var sec = 60;
|
||
var orig = sendBtn.textContent;
|
||
var tick = function(){
|
||
if (sec <= 0) { sendBtn.disabled = false; sendBtn.textContent = orig; return; }
|
||
sendBtn.textContent = '재전송 (' + sec + ')';
|
||
sec -= 1;
|
||
setTimeout(tick, 1000);
|
||
};
|
||
tick();
|
||
} else {
|
||
sendBtn.disabled = false;
|
||
emStatus((r.data && r.data.error) || '전송 실패', 'err');
|
||
}
|
||
return;
|
||
}
|
||
// 닉네임 중복확인 버튼
|
||
var nickBtn = e.target.closest && e.target.closest('[data-nick-check]');
|
||
if (nickBtn) {
|
||
e.preventDefault();
|
||
var nickEl = emEl('[data-nick-input]');
|
||
var statusEl = emEl('[data-nick-status]');
|
||
var nickname = nickEl ? String(nickEl.value || '').trim() : '';
|
||
var setNickStatus = function(text, kind){
|
||
if (!statusEl) return;
|
||
statusEl.textContent = text || '';
|
||
statusEl.className = 'auth-status' + (kind ? ' is-' + kind : '');
|
||
};
|
||
if (!nickname) { setNickStatus('닉네임을 입력하세요', 'err'); return; }
|
||
nickBtn.disabled = true;
|
||
setNickStatus('확인 중...', '');
|
||
var nr = await emPost('/api/auth/check-nickname', { nickname: nickname });
|
||
nickBtn.disabled = false;
|
||
if (nr.ok && nr.data && nr.data.ok) {
|
||
if (nr.data.available) {
|
||
setNickStatus('✓ 사용 가능한 닉네임입니다', 'ok');
|
||
window.__nickVerified = true;
|
||
window.__nickVerifiedFor = nickname;
|
||
} else {
|
||
setNickStatus('이미 사용 중인 닉네임입니다', 'err');
|
||
window.__nickVerified = false;
|
||
}
|
||
} else {
|
||
setNickStatus((nr.data && nr.data.error) || '확인 실패', 'err');
|
||
window.__nickVerified = false;
|
||
}
|
||
refreshSubmit();
|
||
return;
|
||
}
|
||
var verifyBtn = e.target.closest && e.target.closest('[data-em-verify]');
|
||
if (verifyBtn) {
|
||
e.preventDefault();
|
||
var emailEl2 = emEl('[data-em-input]');
|
||
var codeEl = emEl('[data-em-code]');
|
||
var email2 = emailEl2 ? String(emailEl2.value || '').trim() : '';
|
||
var code = String((codeEl && codeEl.value) || '')
|
||
.replace(/[0-9]/g, function(ch){ return String.fromCharCode(ch.charCodeAt(0) - 0xFEE0); })
|
||
.replace(/\D/g, '');
|
||
if (codeEl) codeEl.value = code;
|
||
if (!code) { emStatus('인증코드를 입력하세요', 'err'); return; }
|
||
verifyBtn.disabled = true;
|
||
emStatus('확인 중...', '');
|
||
var r2 = await emPost('/api/auth/verify-email-code', { email: email2, code: code });
|
||
if (r2.ok && r2.data.ok) {
|
||
emStatus('✓ 인증완료', 'ok');
|
||
if (codeEl) codeEl.disabled = true;
|
||
var emEl2 = emEl('[data-em-input]'); if (emEl2) emEl2.readOnly = true;
|
||
window.__emailVerified = true;
|
||
refreshSubmit();
|
||
} else {
|
||
verifyBtn.disabled = false;
|
||
window.__emailVerified = false;
|
||
refreshSubmit();
|
||
emStatus((r2.data && r2.data.error) || '확인 실패', 'err');
|
||
}
|
||
return;
|
||
}
|
||
});
|
||
|
||
// ── 회원가입 폼 클라이언트 검증 ──────────────────────────────────────
|
||
// 비밀번호 강도 / 비번 확인 / PIN 강도 / PIN 확인 / 닉네임 중복확인 / 이메일 인증
|
||
// 모두 충족해야 submit 버튼 활성화. 서버 측 validateSignup 과 동일 규칙.
|
||
window.__nickVerified = false;
|
||
window.__nickVerifiedFor = '';
|
||
window.__emailVerified = false;
|
||
window.__emailVerifiedFor = '';
|
||
|
||
function checkPwStrength(pw, username){
|
||
if (!pw) return { ok: false, msg: '' };
|
||
if (pw.length < 10 || pw.length > 64) return { ok: false, msg: '10~64자' };
|
||
if (/\s/.test(pw)) return { ok: false, msg: '공백 불가' };
|
||
if (!/^[\x21-\x7e]+$/.test(pw)) return { ok: false, msg: '영문/숫자/특수문자만' };
|
||
if (!/[A-Za-z]/.test(pw)) return { ok: false, msg: '영문 포함 필요' };
|
||
if (!/\d/.test(pw)) return { ok: false, msg: '숫자 포함 필요' };
|
||
if (!/[^A-Za-z0-9]/.test(pw)) return { ok: false, msg: '특수문자 포함 필요' };
|
||
// 같은 문자 3회 연속
|
||
for (var i = 0; i + 2 < pw.length; i++) {
|
||
if (pw[i] === pw[i+1] && pw[i+1] === pw[i+2]) return { ok: false, msg: '같은 문자 3회 연속 불가' };
|
||
}
|
||
// 연속 4자 (1234, abcd, qwer)
|
||
var lowerPw = pw.toLowerCase();
|
||
var keyboardRows = ['1234567890', 'qwertyuiop', 'asdfghjkl', 'zxcvbnm', 'abcdefghijklmnopqrstuvwxyz'];
|
||
for (var ki = 0; ki < keyboardRows.length; ki++) {
|
||
var row = keyboardRows[ki];
|
||
for (var kj = 0; kj + 3 < row.length; kj++) {
|
||
var seq = row.substring(kj, kj + 4);
|
||
var rev = seq.split('').reverse().join('');
|
||
if (lowerPw.indexOf(seq) >= 0 || lowerPw.indexOf(rev) >= 0) {
|
||
return { ok: false, msg: '연속 문자 (1234, abcd 등) 불가' };
|
||
}
|
||
}
|
||
}
|
||
// 숫자 연속 4자 (mod 10 — 9012 도)
|
||
for (var di = 0; di + 3 < pw.length; di++) {
|
||
var d0 = pw.charCodeAt(di), d1 = pw.charCodeAt(di+1), d2 = pw.charCodeAt(di+2), d3 = pw.charCodeAt(di+3);
|
||
if (d0 >= 48 && d0 <= 57 && d1 >= 48 && d1 <= 57 && d2 >= 48 && d2 <= 57 && d3 >= 48 && d3 <= 57) {
|
||
var n0 = d0 - 48, n1 = d1 - 48, n2 = d2 - 48, n3 = d3 - 48;
|
||
if (((n1 - n0 + 10) % 10) === 1 && ((n2 - n1 + 10) % 10) === 1 && ((n3 - n2 + 10) % 10) === 1) return { ok: false, msg: '연속 숫자 불가' };
|
||
if (((n0 - n1 + 10) % 10) === 1 && ((n1 - n2 + 10) % 10) === 1 && ((n2 - n3 + 10) % 10) === 1) return { ok: false, msg: '연속 숫자 불가' };
|
||
}
|
||
}
|
||
if (username && username.length >= 3 && lowerPw.indexOf(username.toLowerCase()) >= 0) {
|
||
return { ok: false, msg: '아이디 포함 불가' };
|
||
}
|
||
return { ok: true, msg: '✓ 사용 가능' };
|
||
}
|
||
|
||
function checkPinStrength(pin){
|
||
if (!pin) return { ok: false };
|
||
if (!/^\d{4}$/.test(pin)) return { ok: false, msg: '4자리 숫자' };
|
||
if (/^(\d)\1{3}$/.test(pin)) return { ok: false, msg: '같은 숫자 반복 불가' };
|
||
var ds = pin.split('').map(function(c){ return parseInt(c, 10); });
|
||
var asc = true, desc = true;
|
||
for (var i = 1; i < ds.length; i++) {
|
||
if (((ds[i] - ds[i-1] + 10) % 10) !== 1) asc = false;
|
||
if (((ds[i-1] - ds[i] + 10) % 10) !== 1) desc = false;
|
||
}
|
||
if (asc || desc) return { ok: false, msg: '연속 숫자 불가' };
|
||
return { ok: true };
|
||
}
|
||
|
||
function setStatus(el, msg, kind){
|
||
if (!el) return;
|
||
el.textContent = msg || '';
|
||
el.className = 'auth-status' + (kind ? ' is-' + kind : '');
|
||
}
|
||
|
||
function refreshSubmit(){
|
||
var submit = emEl('[data-em-submit]');
|
||
if (!submit) return;
|
||
var f = emForm();
|
||
if (!f) return;
|
||
var usernameEl = f.querySelector('[name="username"]');
|
||
var nickEl = f.querySelector('[data-nick-input]');
|
||
var pwEl = f.querySelector('[data-pw-input]');
|
||
var pwConfirmEl = f.querySelector('[data-pw-confirm-input]');
|
||
var pinEl = f.querySelector('[name="pin"]');
|
||
var pinConfirmEl = f.querySelector('[name="pinConfirm"]');
|
||
|
||
var username = usernameEl ? String(usernameEl.value || '').trim() : '';
|
||
var nickname = nickEl ? String(nickEl.value || '').trim() : '';
|
||
var pw = pwEl ? String(pwEl.value || '') : '';
|
||
var pwConfirm = pwConfirmEl ? String(pwConfirmEl.value || '') : '';
|
||
var pin = pinEl ? String(pinEl.value || '').trim() : '';
|
||
var pinConfirm = pinConfirmEl ? String(pinConfirmEl.value || '').trim() : '';
|
||
|
||
var usernameOk = /^[A-Za-z0-9_]{3,20}$/.test(username);
|
||
var nickOk = nickname.length >= 2 && nickname.length <= 9 && window.__nickVerified && window.__nickVerifiedFor === nickname;
|
||
var emailOk = !!window.__emailVerified;
|
||
var pwCheck = checkPwStrength(pw, username);
|
||
var pwOk = pwCheck.ok;
|
||
var pwMatch = pw.length > 0 && pw === pwConfirm;
|
||
var pinCheck = checkPinStrength(pin);
|
||
var pinOk = pinCheck.ok;
|
||
var pinMatch = pin.length === 4 && pin === pinConfirm;
|
||
|
||
submit.disabled = !(usernameOk && nickOk && emailOk && pwOk && pwMatch && pinOk && pinMatch);
|
||
|
||
// submit hover 시 어떤 항목이 안 됐는지 안내
|
||
if (submit.disabled) {
|
||
var missing = [];
|
||
if (!usernameOk) missing.push('아이디 (3~20자 영문/숫자/_)');
|
||
if (!emailOk) missing.push('이메일 인증');
|
||
if (!nickOk) missing.push(nickname.length < 2 ? '닉네임 (2~9자)' : (window.__nickVerifiedFor !== nickname ? '닉네임 중복확인' : '닉네임 검증'));
|
||
if (!pwOk) missing.push('비밀번호: ' + (pwCheck.msg || '필요 요건 미충족'));
|
||
if (pwOk && !pwMatch) missing.push('비밀번호 확인 일치');
|
||
if (!pinOk) missing.push('PIN: ' + (pinCheck.msg || '필요 요건 미충족'));
|
||
if (pinOk && !pinMatch) missing.push('PIN 확인 일치');
|
||
submit.title = '아직 입력 안 된 항목:\\n• ' + missing.join('\\n• ');
|
||
} else {
|
||
submit.title = '';
|
||
}
|
||
}
|
||
|
||
// 비밀번호 실시간 검증
|
||
document.addEventListener('input', function(e){
|
||
var el = e.target;
|
||
if (!el || !el.matches) return;
|
||
if (el.matches('[data-pw-input]')) {
|
||
var pw = String(el.value || '');
|
||
var f = emForm(); if (!f) return;
|
||
var usernameEl = f.querySelector('[name="username"]');
|
||
var username = usernameEl ? String(usernameEl.value || '').trim() : '';
|
||
var statusEl = f.querySelector('[data-pw-status]');
|
||
if (pw === '') {
|
||
setStatus(statusEl, '10~64자 · 영문+숫자+특수문자 모두 포함 · 연속/반복/흔한 패턴 금지', '');
|
||
} else {
|
||
var res = checkPwStrength(pw, username);
|
||
setStatus(statusEl, res.msg || '', res.ok ? 'ok' : 'err');
|
||
}
|
||
// 확인란도 재평가
|
||
var pwConfirmEl = f.querySelector('[data-pw-confirm-input]');
|
||
var pwConfirmStatus = f.querySelector('[data-pw-confirm-status]');
|
||
if (pwConfirmEl && pwConfirmEl.value) {
|
||
if (pwConfirmEl.value === pw) setStatus(pwConfirmStatus, '✓ 일치', 'ok');
|
||
else setStatus(pwConfirmStatus, '불일치', 'err');
|
||
} else {
|
||
setStatus(pwConfirmStatus, '', '');
|
||
}
|
||
refreshSubmit();
|
||
return;
|
||
}
|
||
if (el.matches('[data-pw-confirm-input]')) {
|
||
var f2 = emForm(); if (!f2) return;
|
||
var pwEl2 = f2.querySelector('[data-pw-input]');
|
||
var pw2 = pwEl2 ? String(pwEl2.value || '') : '';
|
||
var statusEl2 = f2.querySelector('[data-pw-confirm-status]');
|
||
var conf = String(el.value || '');
|
||
if (conf === '') setStatus(statusEl2, '', '');
|
||
else if (conf === pw2) setStatus(statusEl2, '✓ 일치', 'ok');
|
||
else setStatus(statusEl2, '불일치', 'err');
|
||
refreshSubmit();
|
||
return;
|
||
}
|
||
if (el.matches('[name="username"]')) {
|
||
// username 변경 시 비번 정책 (username 포함 금지) 재평가
|
||
var f3 = emForm(); if (!f3) return;
|
||
var pwEl3 = f3.querySelector('[data-pw-input]');
|
||
if (pwEl3 && pwEl3.value) {
|
||
pwEl3.dispatchEvent(new Event('input', { bubbles: true }));
|
||
} else {
|
||
refreshSubmit();
|
||
}
|
||
return;
|
||
}
|
||
if (el.matches('[data-nick-input]')) {
|
||
// 닉네임 변경 시 중복확인 다시 받아야 함
|
||
if (window.__nickVerifiedFor !== String(el.value || '').trim()) {
|
||
window.__nickVerified = false;
|
||
var statusEl3 = emEl('[data-nick-status]');
|
||
if (statusEl3 && statusEl3.textContent && statusEl3.textContent.indexOf('✓') === 0) {
|
||
setStatus(statusEl3, '닉네임 변경됨 — 중복확인 다시', '');
|
||
}
|
||
}
|
||
refreshSubmit();
|
||
return;
|
||
}
|
||
if (el.matches('[name="pin"]') || el.matches('[name="pinConfirm"]')) {
|
||
refreshSubmit();
|
||
return;
|
||
}
|
||
});
|
||
// 인증코드칸 — 키 입력 단계에서 비숫자 차단
|
||
document.addEventListener('keydown', function(e){
|
||
var el = e.target;
|
||
if (!el || !el.matches || !el.matches('[data-em-code]')) return;
|
||
if (e.ctrlKey || e.metaKey || e.altKey) return;
|
||
if (e.key && e.key.length === 1 && !/^\d$/.test(e.key)) {
|
||
e.preventDefault();
|
||
}
|
||
});
|
||
// paste / IME / 전각 숫자 등 — 사후 정리
|
||
document.addEventListener('input', function(e){
|
||
var el = e.target;
|
||
if (!el || !el.matches) return;
|
||
if (el.matches('[data-em-code]')) {
|
||
var v = String(el.value || '')
|
||
.replace(/[0-9]/g, function(ch){ return String.fromCharCode(ch.charCodeAt(0) - 0xFEE0); })
|
||
.replace(/\D/g, '')
|
||
.slice(0, 6);
|
||
if (v !== el.value) el.value = v;
|
||
return;
|
||
}
|
||
if (el.matches('[data-em-input]')) {
|
||
var emailEl3 = emEl('[data-em-input]');
|
||
if (emailEl3 && emailEl3.readOnly) {
|
||
emailEl3.readOnly = false;
|
||
emReset();
|
||
}
|
||
}
|
||
});
|
||
}
|
||
// 글로벌 클릭 핸들러 한 번만 등록
|
||
if (window.__authBound) return;
|
||
window.__authBound = true;
|
||
document.addEventListener('click', function(e){
|
||
var t = e.target.closest && e.target.closest('[data-auth-open]');
|
||
if (t) {
|
||
e.preventDefault();
|
||
open(t.getAttribute('data-auth-open'));
|
||
return;
|
||
}
|
||
if (e.target.closest && e.target.closest('[data-auth-close]')) {
|
||
close();
|
||
return;
|
||
}
|
||
var tab = e.target.closest && e.target.closest('[data-auth-tab]');
|
||
var m = modal();
|
||
if (tab && m && m.contains(tab)) {
|
||
setTab(tab.getAttribute('data-auth-tab'));
|
||
return;
|
||
}
|
||
if (m && e.target === m) close();
|
||
});
|
||
|
||
// PIN 입력 — 숫자만 (회원가입 폼의 pin / pinConfirm input). 키보드/붙여넣기/IME 입력 모두 필터.
|
||
// 입력 즉시 약한 PIN 안내 메시지 표시 (1111, 1234 등). 회원가입 폼의 pin status span 에 직접 출력.
|
||
function _pinStrengthCheck(pin) {
|
||
if (!/^\d{4}$/.test(pin)) return { ok: false, msg: '4자리 숫자' };
|
||
if (/^(\d)\1{3}$/.test(pin)) return { ok: false, msg: '같은 숫자 반복(예: 1111)은 사용 불가' };
|
||
var ds = pin.split('').map(function(c){ return parseInt(c, 10); });
|
||
var asc = true, desc = true;
|
||
for (var i = 1; i < ds.length; i++) {
|
||
if (((ds[i] - ds[i - 1] + 10) % 10) !== 1) asc = false;
|
||
if (((ds[i - 1] - ds[i] + 10) % 10) !== 1) desc = false;
|
||
}
|
||
if (asc || desc) return { ok: false, msg: '연속된 숫자(예: 1234, 9876)는 사용 불가' };
|
||
return { ok: true };
|
||
}
|
||
function _setStatus(el, msg, kind) {
|
||
if (!el) return;
|
||
el.textContent = msg || '';
|
||
el.className = 'auth-status' + (kind ? ' is-' + kind : '');
|
||
}
|
||
document.addEventListener('input', function(e){
|
||
var t = e.target;
|
||
if (!t || !t.matches || !t.matches('[data-pin-input]')) return;
|
||
var cleaned = String(t.value || '').replace(/\D+/g, '').slice(0, 4);
|
||
if (t.value !== cleaned) t.value = cleaned;
|
||
var f = document.querySelector('[data-em-form]');
|
||
if (!f) return;
|
||
if (t.name === 'pin') {
|
||
var pinStatus = f.querySelector('[data-pin-status]');
|
||
if (pinStatus) {
|
||
if (cleaned === '') {
|
||
_setStatus(pinStatus, '로그인 시 비밀번호 입력 후 한 번 더 묻습니다. 연속 숫자(1234) / 같은 숫자 반복(1111) 은 사용 불가.', '');
|
||
} else if (cleaned.length < 4) {
|
||
_setStatus(pinStatus, '4자리 숫자를 입력해주세요', '');
|
||
} else {
|
||
var res = _pinStrengthCheck(cleaned);
|
||
_setStatus(pinStatus, res.ok ? '✓ 사용 가능한 PIN' : res.msg, res.ok ? 'ok' : 'err');
|
||
}
|
||
}
|
||
// pin 변경되면 pinConfirm status 도 즉시 재평가.
|
||
var pinConfirmEl = f.querySelector('[name="pinConfirm"]');
|
||
var pinConfirmStatus = f.querySelector('[data-pin-confirm-status]');
|
||
if (pinConfirmEl && pinConfirmStatus && pinConfirmEl.value) {
|
||
if (pinConfirmEl.value === cleaned && cleaned.length === 4) _setStatus(pinConfirmStatus, '✓ 일치', 'ok');
|
||
else _setStatus(pinConfirmStatus, 'PIN 이 일치하지 않습니다', 'err');
|
||
}
|
||
}
|
||
if (t.name === 'pinConfirm') {
|
||
var pinConfirmStatus2 = f.querySelector('[data-pin-confirm-status]');
|
||
if (pinConfirmStatus2) {
|
||
var pinEl = f.querySelector('[name="pin"]');
|
||
var firstPin = pinEl ? String(pinEl.value || '') : '';
|
||
if (cleaned === '') _setStatus(pinConfirmStatus2, '', '');
|
||
else if (cleaned === firstPin && firstPin.length === 4) _setStatus(pinConfirmStatus2, '✓ 일치', 'ok');
|
||
else _setStatus(pinConfirmStatus2, 'PIN 이 일치하지 않습니다', 'err');
|
||
}
|
||
}
|
||
}, true);
|
||
document.addEventListener('keypress', function(e){
|
||
var t = e.target;
|
||
if (!t || !t.matches || !t.matches('[data-pin-input]')) return;
|
||
if (e.key.length === 1 && !/^\d$/.test(e.key)) e.preventDefault();
|
||
}, true);
|
||
|
||
// ─── 로그인 fetch 화 + PIN 모달 흐름 ───
|
||
var pendingToken = null;
|
||
var pendingNext = null; // "pin-verify" | "pin-setup"
|
||
var pinBuffer = "";
|
||
var pinLength = 4; // 서버 응답의 pinLength 로 동적 설정 (기존 5/6자리 사용자 호환)
|
||
var setupFirst = null; // setup 흐름의 1차 PIN
|
||
var setupStep = "first"; // "first" | "confirm"
|
||
|
||
function pinPanel(){ var m = modal(); return m ? m.querySelector('[data-auth-panel="pin"]') : null; }
|
||
function setPinError(msg){
|
||
var p = pinPanel(); if (!p) return;
|
||
var el = p.querySelector('[data-pin-error]');
|
||
if (el) el.textContent = msg || "";
|
||
}
|
||
function setPinTitle(t, d){
|
||
var p = pinPanel(); if (!p) return;
|
||
var tt = p.querySelector('[data-pin-title]');
|
||
var dd = p.querySelector('[data-pin-desc]');
|
||
if (tt) tt.textContent = t;
|
||
if (dd) dd.textContent = d;
|
||
}
|
||
function renderDots(){
|
||
var p = pinPanel(); if (!p) return;
|
||
var holder = p.querySelector('[data-pin-dots]');
|
||
if (!holder) return;
|
||
// 기존 dots 모두 제거 후 pinLength 만큼 새로 생성
|
||
while (holder.firstChild) holder.removeChild(holder.firstChild);
|
||
for (var i = 0; i < pinLength; i++) {
|
||
var dot = document.createElement('span');
|
||
dot.setAttribute('data-pin-dot', '');
|
||
dot.setAttribute('data-idx', String(i));
|
||
dot.style.cssText = 'display:inline-block;width:12px;height:12px;border-radius:50%;border:2px solid var(--brand, #e63946);background:' + (i < pinBuffer.length ? 'var(--brand, #e63946)' : 'transparent');
|
||
holder.appendChild(dot);
|
||
}
|
||
}
|
||
function shuffleDigits(){
|
||
var arr = [0,1,2,3,4,5,6,7,8,9];
|
||
for (var i = arr.length - 1; i > 0; i--){
|
||
var j = Math.floor(Math.random() * (i + 1));
|
||
var tmp = arr[i]; arr[i] = arr[j]; arr[j] = tmp;
|
||
}
|
||
return arr;
|
||
}
|
||
function renderKeys(){
|
||
var p = pinPanel(); if (!p) return;
|
||
var digits = shuffleDigits();
|
||
// 12 슬롯: [0..8] = digits[0..8], [9] = clear, [10] = digits[9], [11] = back
|
||
p.querySelectorAll('[data-pin-key]').forEach(function(btn){
|
||
var slot = parseInt(btn.getAttribute('data-slot'), 10);
|
||
if (slot === 9) { btn.textContent = '전체삭제'; btn.setAttribute('data-action', 'clear'); btn.style.color = 'var(--text-muted, #888)'; btn.style.background = 'var(--surface-2, #f7f7fa)'; }
|
||
else if (slot === 11) { btn.textContent = '←'; btn.setAttribute('data-action', 'back'); btn.style.color = 'var(--text-muted, #888)'; btn.style.background = 'var(--surface-2, #f7f7fa)'; }
|
||
else {
|
||
var idx = slot === 10 ? 9 : slot; // slot 10 → digits[9]
|
||
var d = digits[idx];
|
||
btn.textContent = String(d);
|
||
btn.setAttribute('data-digit', String(d));
|
||
btn.removeAttribute('data-action');
|
||
btn.style.color = 'inherit';
|
||
btn.style.background = 'var(--panel, #fff)';
|
||
}
|
||
});
|
||
}
|
||
function resetPinUi(){
|
||
pinBuffer = "";
|
||
setPinError("");
|
||
renderDots();
|
||
renderKeys();
|
||
}
|
||
function showPinPanel(next, len){
|
||
pendingNext = next;
|
||
pinLength = (len === 4 || len === 5 || len === 6) ? len : 4;
|
||
setupFirst = null;
|
||
setupStep = "first";
|
||
if (next === "pin-setup") {
|
||
pinLength = 4; // 신규 등록은 4자리 강제
|
||
setPinTitle("🔐 2차 비밀번호 등록", "처음 로그인이시네요. PIN 4자리를 등록해주세요. 연속된 숫자(1234, 9876) / 같은 숫자 반복(1111)은 사용 불가.");
|
||
} else {
|
||
setPinTitle("🔒 2차 비밀번호 입력", "키패드로 PIN(" + pinLength + "자리) 을 입력해주세요.");
|
||
}
|
||
resetPinUi();
|
||
setTab("pin");
|
||
}
|
||
|
||
async function submitVerify(pin){
|
||
setPinError("");
|
||
try {
|
||
var res = await fetch("/api/auth/login-pin", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ pendingToken: pendingToken, pin: pin }),
|
||
});
|
||
var data = await res.json().catch(function(){ return null; });
|
||
if (!res.ok || !data || !data.ok) {
|
||
setPinError((data && data.error) || "PIN 오류");
|
||
resetPinUi();
|
||
return;
|
||
}
|
||
window.location.reload();
|
||
} catch (e) {
|
||
setPinError("네트워크 오류");
|
||
resetPinUi();
|
||
}
|
||
}
|
||
async function submitSetup(first, second){
|
||
setPinError("");
|
||
try {
|
||
var res = await fetch("/api/auth/setup-pin", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ pendingToken: pendingToken, pin: first, pinConfirm: second }),
|
||
});
|
||
var data = await res.json().catch(function(){ return null; });
|
||
if (!res.ok || !data || !data.ok) {
|
||
setPinError((data && data.error) || "등록 실패");
|
||
setupFirst = null;
|
||
setupStep = "first";
|
||
setPinTitle("🔐 2차 비밀번호 등록", "다시 입력해주세요.");
|
||
resetPinUi();
|
||
return;
|
||
}
|
||
window.location.reload();
|
||
} catch (e) {
|
||
setPinError("네트워크 오류");
|
||
resetPinUi();
|
||
}
|
||
}
|
||
|
||
function onPinComplete(pin){
|
||
if (pendingNext === "pin-verify") {
|
||
submitVerify(pin);
|
||
} else if (pendingNext === "pin-setup") {
|
||
if (setupStep === "first") {
|
||
setupFirst = pin;
|
||
setupStep = "confirm";
|
||
setPinTitle("🔐 2차 비밀번호 등록", "다시 한 번 입력해 확인해주세요");
|
||
pinBuffer = "";
|
||
renderDots();
|
||
renderKeys();
|
||
} else {
|
||
submitSetup(setupFirst, pin);
|
||
}
|
||
}
|
||
}
|
||
|
||
document.addEventListener('click', function(e){
|
||
var btn = e.target && e.target.closest && e.target.closest('[data-pin-key]');
|
||
if (!btn) return;
|
||
var act = btn.getAttribute('data-action');
|
||
if (act === 'clear') {
|
||
pinBuffer = "";
|
||
renderDots();
|
||
return;
|
||
}
|
||
if (act === 'back') {
|
||
pinBuffer = pinBuffer.slice(0, -1);
|
||
renderDots();
|
||
return;
|
||
}
|
||
var d = btn.getAttribute('data-digit');
|
||
if (d == null) return;
|
||
if (pinBuffer.length >= pinLength) return;
|
||
pinBuffer += d;
|
||
renderDots();
|
||
if (pinBuffer.length === pinLength) {
|
||
// 자동 제출 — 사용자별 PIN 길이에 도달하면 호출.
|
||
setTimeout(function(){ onPinComplete(pinBuffer); }, 80);
|
||
}
|
||
});
|
||
document.addEventListener('click', function(e){
|
||
var b = e.target && e.target.closest && e.target.closest('[data-pin-back-to-login]');
|
||
if (!b) return;
|
||
pendingToken = null;
|
||
setTab('login');
|
||
});
|
||
|
||
// 로그인 form intercept — submit 시 fetch 로 처리. 비번 OK + next=pin-* 이면 PIN 패널 표시.
|
||
document.addEventListener('submit', function(e){
|
||
var form = e.target && e.target.closest && e.target.closest('[data-auth-panel="login"] form');
|
||
if (!form) return;
|
||
e.preventDefault();
|
||
var fd = new FormData(form);
|
||
var body = {};
|
||
fd.forEach(function(v, k){ body[k] = String(v); });
|
||
fetch("/api/auth/login", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json", "Accept": "application/json" },
|
||
body: JSON.stringify(body),
|
||
}).then(function(r){ return r.json().then(function(j){ return { status: r.status, body: j }; }); })
|
||
.then(function(res){
|
||
if (res.body && res.body.ok && res.body.next && res.body.pendingToken) {
|
||
pendingToken = res.body.pendingToken;
|
||
showPinPanel(res.body.next, res.body.pinLength);
|
||
return;
|
||
}
|
||
// 에러
|
||
var msg = (res.body && res.body.error) || "로그인 실패";
|
||
alert(msg);
|
||
}).catch(function(){ alert("네트워크 오류"); });
|
||
});
|
||
})();
|