445 lines
18 KiB
PHP
445 lines
18 KiB
PHP
@extends('web.layouts.auth')
|
|
|
|
@section('title', '비밀번호 찾기 | PIN FOR YOU')
|
|
@section('meta_description', 'PIN FOR YOU 비밀번호 찾기 페이지입니다.')
|
|
@section('canonical', url('/auth/find-password'))
|
|
|
|
@section('h1', '비밀번호 찾기')
|
|
@section('desc', '가입된 이메일과 성명 확인 후, 비밀번호 재설정 링크를 이메일로 보내드립니다.')
|
|
@section('card_aria', '비밀번호 찾기 폼')
|
|
@section('show_cs_links', true)
|
|
|
|
{{-- reCAPTCHA 스크립트/공통함수는 이 페이지에서만 로드 --}}
|
|
@push('recaptcha')
|
|
<script>window.__recaptchaSiteKey = @json(config('services.recaptcha.site_key'));</script>
|
|
<script src="https://www.google.com/recaptcha/api.js?render={{ config('services.recaptcha.site_key') }}"></script>
|
|
<script src="{{ asset('assets/js/recaptcha-v3.js') }}"></script>
|
|
@endpush
|
|
|
|
@section('auth_content')
|
|
<form class="auth-form" id="findPwForm" onsubmit="return false;">
|
|
<input type="hidden" name="g-recaptcha-response" id="g-recaptcha-response" value="">
|
|
{{-- STEP 1: 이메일 + 성명 --}}
|
|
<div class="auth-panel is-active" data-step="1">
|
|
<div class="auth-field">
|
|
<label class="auth-label" for="fp_email">아이디(이메일)</label>
|
|
<input class="auth-input"
|
|
id="fp_email"
|
|
type="email"
|
|
placeholder="example@domain.com"
|
|
autocomplete="username"
|
|
value="{{ $email ?? '' }}">
|
|
<div class="auth-help">가입된 이메일을 입력해 주세요.</div>
|
|
</div>
|
|
|
|
<div class="auth-field">
|
|
<label class="auth-label" for="fp_name">성명</label>
|
|
<input class="auth-input"
|
|
id="fp_name"
|
|
type="text"
|
|
placeholder="가입 시 등록한 성명"
|
|
autocomplete="name"
|
|
value="{{ $name ?? '' }}">
|
|
<div class="auth-help">가입 시 등록한 성명을 입력해 주세요.</div>
|
|
</div>
|
|
|
|
<div class="auth-actions">
|
|
<button id="btnSendMail" class="auth-btn auth-btn--primary" type="button" data-send>
|
|
재설정 메일 발송
|
|
</button>
|
|
<a class="auth-btn auth-btn--ghost" href="{{ route('web.auth.login') }}">로그인으로 돌아가기</a>
|
|
</div>
|
|
</div>
|
|
|
|
{{-- STEP 2: 메일 발송 안내 + 유효시간 + 재발송 --}}
|
|
<div class="auth-panel" data-step="2">
|
|
<div class="auth-field">
|
|
<div class="auth-help" style="line-height:1.7;">
|
|
입력하신 정보가 확인되면 <b>비밀번호 재설정 링크</b>를 이메일로 보내드립니다.<br>
|
|
메일이 오지 않으면 스팸함/격리함을 확인해 주세요.
|
|
</div>
|
|
|
|
{{-- 타이머 + 재발송 UI --}}
|
|
<div class="fp-timer" style="margin-top:10px; display:flex; gap:10px; align-items:center;">
|
|
<span class="fp-timer__text" style="font-size:13px; color:#c7c7c7;"></span>
|
|
<button id="btnResendMail" type="button" class="auth-btn auth-btn--ghost" data-resend style="padding:10px 12px;">
|
|
메일 재발송
|
|
</button>
|
|
</div>
|
|
|
|
<div class="auth-help" style="margin-top:10px;">
|
|
링크는 <b>30분</b> 동안만 유효합니다.
|
|
</div>
|
|
</div>
|
|
|
|
<div class="auth-actions">
|
|
<button class="auth-btn auth-btn--ghost" type="button" data-prev>이전</button>
|
|
</div>
|
|
</div>
|
|
|
|
{{-- STEP 3: 새 비밀번호 입력 (링크 검증 완료 시 진입) --}}
|
|
<div class="auth-panel" data-step="3">
|
|
<div class="auth-field">
|
|
<label class="auth-label" for="fp_new">새 비밀번호 <small>8자 이상 권장</small></label>
|
|
<input class="auth-input"
|
|
id="fp_new"
|
|
type="password"
|
|
placeholder="새 비밀번호"
|
|
autocomplete="new-password">
|
|
</div>
|
|
|
|
<div class="auth-field" style="margin-top:20px">
|
|
<label class="auth-label" for="fp_new2">새 비밀번호 확인</label>
|
|
<input class="auth-input"
|
|
id="fp_new2"
|
|
type="password"
|
|
placeholder="새 비밀번호 재입력"
|
|
autocomplete="new-password">
|
|
</div>
|
|
|
|
<div class="auth-actions">
|
|
<button class="auth-btn auth-btn--primary" type="button" data-reset>비밀번호 변경</button>
|
|
{{-- <a class="auth-btn auth-btn--ghost" href="{{ route('web.auth.login') }}">로그인하기</a>--}}
|
|
</div>
|
|
|
|
<div class="auth-help" style="margin-top:10px;">
|
|
이메일 링크 인증이 완료된 상태에서만 비밀번호 재설정이 가능합니다.
|
|
</div>
|
|
</div>
|
|
</form>
|
|
@endsection
|
|
|
|
@section('auth_bottom')
|
|
@endsection
|
|
|
|
@push('scripts')
|
|
<script>
|
|
(function(){
|
|
const root = document.getElementById('findPwForm');
|
|
if(!root) return;
|
|
|
|
const panels = root.querySelectorAll('.auth-panel');
|
|
const stepInd = document.querySelectorAll('.auth-step');
|
|
|
|
let step = Number(@json($initialStep ?? 1));
|
|
|
|
const $email = document.getElementById('fp_email');
|
|
const $name = document.getElementById('fp_name');
|
|
const $newPw = document.getElementById('fp_new');
|
|
const $newPw2= document.getElementById('fp_new2');
|
|
|
|
const btnSend = document.getElementById('btnSendMail');
|
|
const btnResend = document.getElementById('btnResendMail');
|
|
|
|
// recaptcha hidden input (없으면 생성)
|
|
const ensureRecaptchaInput = () => {
|
|
let el = root.querySelector('input[name="g-recaptcha-response"]');
|
|
if(!el){
|
|
el = document.createElement('input');
|
|
el.type = 'hidden';
|
|
el.name = 'g-recaptcha-response';
|
|
el.id = 'g-recaptcha-response';
|
|
el.value = '';
|
|
root.prepend(el);
|
|
}
|
|
return el;
|
|
};
|
|
|
|
// ---------- message ----------
|
|
const mkMsg = () => {
|
|
let el = root.querySelector('.auth-msg');
|
|
if(!el){
|
|
el = document.createElement('div');
|
|
el.className = 'auth-msg';
|
|
el.style.marginTop = '10px';
|
|
el.style.fontSize = '13px';
|
|
el.style.lineHeight = '1.4';
|
|
}
|
|
const activeActions = root.querySelector('.auth-panel.is-active .auth-actions');
|
|
if(activeActions && el.parentNode !== activeActions){
|
|
activeActions.prepend(el);
|
|
}
|
|
return el;
|
|
};
|
|
|
|
const setMsg = (text, type='info') => {
|
|
const el = mkMsg();
|
|
el.textContent = text || '';
|
|
el.style.color =
|
|
(type === 'error') ? '#ff6b6b' :
|
|
(type === 'success') ? '#2ecc71' :
|
|
'#c7c7c7';
|
|
};
|
|
|
|
const render = () => {
|
|
const activeEl = document.activeElement;
|
|
if (activeEl && root.contains(activeEl)) activeEl.blur();
|
|
|
|
panels.forEach(p => {
|
|
const on = Number(p.dataset.step) === step;
|
|
p.classList.toggle('is-active', on);
|
|
p.style.display = on ? 'block' : 'none';
|
|
|
|
if (!on) {
|
|
p.setAttribute('aria-hidden', 'true');
|
|
p.setAttribute('inert', '');
|
|
} else {
|
|
p.setAttribute('aria-hidden', 'false');
|
|
p.removeAttribute('inert');
|
|
}
|
|
});
|
|
|
|
stepInd.forEach(s => {
|
|
const on = Number(s.dataset.stepInd) === step;
|
|
s.classList.toggle('is-active', on);
|
|
});
|
|
|
|
mkMsg();
|
|
|
|
const target = root.querySelector(`.auth-panel[data-step="${step}"] input, .auth-panel[data-step="${step}"] button, .auth-panel[data-step="${step}"] a`);
|
|
target?.focus?.();
|
|
};
|
|
|
|
// -------- helpers ----------
|
|
const csrf = () => document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '';
|
|
|
|
// recaptcha token getter
|
|
const getRecaptchaToken = async (action) => {
|
|
// production에서만 검증하지만, 프론트는 그냥 항상 시도해도 OK
|
|
const siteKey = window.__recaptchaSiteKey || '';
|
|
if(!siteKey) return '';
|
|
|
|
// grecaptcha 로딩 체크
|
|
if(typeof window.grecaptcha === 'undefined' || !window.grecaptcha?.execute){
|
|
return '';
|
|
}
|
|
|
|
try{
|
|
// ready 보장
|
|
await new Promise((resolve) => window.grecaptcha.ready(resolve));
|
|
const token = await window.grecaptcha.execute(siteKey, { action: action || 'find_pass' });
|
|
|
|
const input = ensureRecaptchaInput();
|
|
input.value = token || '';
|
|
|
|
return token || '';
|
|
}catch(e){
|
|
return '';
|
|
}
|
|
};
|
|
|
|
const postJson = async (url, data) => {
|
|
const res = await fetch(url, {
|
|
method: 'POST',
|
|
credentials: 'same-origin',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-TOKEN': csrf(),
|
|
'X-Requested-With': 'XMLHttpRequest',
|
|
'Accept': 'application/json',
|
|
},
|
|
body: JSON.stringify(data || {})
|
|
});
|
|
|
|
const ct = res.headers.get('content-type') || '';
|
|
const raw = await res.text();
|
|
|
|
let json = null;
|
|
if (ct.includes('application/json')) {
|
|
try { json = JSON.parse(raw); } catch(e){}
|
|
}
|
|
|
|
if (!res.ok) {
|
|
const msg = json?.message || `요청 실패 (${res.status})`;
|
|
const err = new Error(msg);
|
|
err.status = res.status;
|
|
err.payload = json;
|
|
err.raw = raw;
|
|
throw err;
|
|
}
|
|
|
|
return json ?? { ok: true };
|
|
};
|
|
|
|
// -------- sending button animation ----------
|
|
const makeSendingAnimator = () => {
|
|
let timer = null;
|
|
return (btn, busy, finalTextAfter = null) => {
|
|
if (!btn) return;
|
|
|
|
if (busy) {
|
|
btn.disabled = true;
|
|
btn.dataset.prevText = btn.dataset.prevText || btn.textContent.trim();
|
|
btn.setAttribute('aria-busy', 'true');
|
|
|
|
let dots = 0;
|
|
btn.textContent = '발송중';
|
|
timer = setInterval(() => {
|
|
dots = (dots + 1) % 4;
|
|
btn.textContent = '발송중' + '.'.repeat(dots);
|
|
}, 320);
|
|
} else {
|
|
btn.disabled = false;
|
|
btn.removeAttribute('aria-busy');
|
|
if (timer) { clearInterval(timer); timer = null; }
|
|
|
|
if (finalTextAfter) {
|
|
btn.dataset.prevText = finalTextAfter;
|
|
btn.textContent = finalTextAfter;
|
|
} else {
|
|
btn.textContent = btn.dataset.prevText || btn.textContent;
|
|
}
|
|
}
|
|
};
|
|
};
|
|
const setBtnBusy = makeSendingAnimator();
|
|
const setResendLabel = () => {
|
|
if (btnSend) btnSend.textContent = '메일 재발송';
|
|
if (btnResend) btnResend.textContent = '메일 재발송';
|
|
};
|
|
|
|
// -------- timer ----------
|
|
let timerId = null;
|
|
let remain = 0;
|
|
|
|
const tick = () => {
|
|
const t = root.querySelector('.fp-timer__text');
|
|
const mm = String(Math.floor(remain/60)).padStart(2,'0');
|
|
const ss = String(remain%60).padStart(2,'0');
|
|
|
|
if (t) {
|
|
t.textContent = remain > 0
|
|
? `링크 유효시간 ${mm}:${ss}`
|
|
: '링크가 만료되었습니다. 메일 재발송을 진행해 주세요.';
|
|
}
|
|
|
|
if(remain <= 0){
|
|
clearInterval(timerId);
|
|
timerId = null;
|
|
return;
|
|
}
|
|
remain -= 1;
|
|
};
|
|
|
|
const startTimer = (sec) => {
|
|
remain = Number(sec || 1800); // 30분 기본
|
|
if(timerId) clearInterval(timerId);
|
|
timerId = setInterval(tick, 1000);
|
|
tick();
|
|
};
|
|
|
|
// -------- actions ----------
|
|
const sendResetMail = async (fromResend = false) => {
|
|
const email = ($email?.value || '').trim();
|
|
const name = ($name?.value || '').trim();
|
|
|
|
if(!email){
|
|
setMsg('이메일을 입력해 주세요.', 'error');
|
|
step = 1; render();
|
|
return;
|
|
}
|
|
if(!name){
|
|
setMsg('성명을 입력해 주세요.', 'error');
|
|
step = 1; render();
|
|
return;
|
|
}
|
|
|
|
setMsg('확인 중입니다...', 'info');
|
|
|
|
const targetBtn = fromResend ? btnResend : btnSend;
|
|
setBtnBusy(targetBtn, true);
|
|
|
|
try {
|
|
// 요청 직전에 토큰 생성해서 body에 포함
|
|
const token = await getRecaptchaToken('find_pass');
|
|
|
|
if (!token) {
|
|
// 로컬/개발에선 괜찮을 수 있지만, production이면 여기서 막아도 됨
|
|
// 너 정책대로: production에서만 required니까 일단 안내만.
|
|
setMsg('보안 검증(캡챠) 로딩에 실패했습니다. 새로고침 후 다시 시도해 주세요.', 'error');
|
|
return;
|
|
}
|
|
|
|
const json = await postJson(@json(route('web.auth.find_password.send_mail')), {
|
|
email,
|
|
name,
|
|
'g-recaptcha-response': token,
|
|
});
|
|
|
|
setMsg(json.message || '재설정 메일을 발송했습니다. 메일함을 확인해 주세요.', 'success');
|
|
|
|
step = 2;
|
|
render();
|
|
|
|
startTimer(json.expires_in || 1800);
|
|
setResendLabel();
|
|
|
|
} catch (err) {
|
|
setMsg(err.message || '오류가 발생했습니다.', 'error');
|
|
} finally {
|
|
setBtnBusy(targetBtn, false, '메일 재발송');
|
|
}
|
|
};
|
|
|
|
const resetPassword = async () => {
|
|
const pw1 = ($newPw?.value || '');
|
|
const pw2 = ($newPw2?.value || '');
|
|
|
|
if(!pw1 || pw1.length < 8){
|
|
setMsg('비밀번호는 8자 이상으로 입력해 주세요.', 'error');
|
|
return;
|
|
}
|
|
if(pw1 !== pw2){
|
|
setMsg('비밀번호 확인이 일치하지 않습니다.', 'error');
|
|
return;
|
|
}
|
|
|
|
setMsg('변경 처리 중입니다...', 'info');
|
|
|
|
try {
|
|
// (비번 변경도 캡챠 걸 거면 여기에도 token 추가하면 됨)
|
|
const json = await postJson(@json(route('web.auth.find_password.reset')), {
|
|
new_password: pw1,
|
|
new_password_confirmation: pw2
|
|
});
|
|
|
|
setMsg(json.message || '비밀번호가 변경되었습니다. 로그인해 주세요.', 'success');
|
|
|
|
if (json.redirect_url) {
|
|
setTimeout(() => { window.location.href = json.redirect_url; }, 800);
|
|
}
|
|
} catch (err) {
|
|
setMsg(err.message || '오류가 발생했습니다.', 'error');
|
|
}
|
|
};
|
|
|
|
// -------- events ----------
|
|
root.addEventListener('click', async (e) => {
|
|
const send = e.target.closest('[data-send]');
|
|
const resend = e.target.closest('[data-resend]');
|
|
const prev = e.target.closest('[data-prev]');
|
|
const reset = e.target.closest('[data-reset]');
|
|
|
|
if(send){ await sendResetMail(false); return; }
|
|
if(resend){ await sendResetMail(true); return; }
|
|
if(prev){ step = Math.max(1, step - 1); render(); return; }
|
|
if(reset){ await resetPassword(); return; }
|
|
});
|
|
|
|
// Enter UX
|
|
$email?.addEventListener('keydown', (e) => {
|
|
if(e.key === 'Enter'){ e.preventDefault(); btnSend?.click(); }
|
|
});
|
|
$name?.addEventListener('keydown', (e) => {
|
|
if(e.key === 'Enter'){ e.preventDefault(); btnSend?.click(); }
|
|
});
|
|
$newPw2?.addEventListener('keydown', (e) => {
|
|
if(e.key === 'Enter'){ e.preventDefault(); root.querySelector('[data-reset]')?.click(); }
|
|
});
|
|
|
|
render();
|
|
})();
|
|
</script>
|
|
|
|
@endpush
|