fix(A06): 인증 코드 단계가 새로고침에 사라지던 문제
휴대폰에서 코드를 보러 메일 앱에 다녀오면 브라우저가 탭을 버려 페이지가 다시 뜨는데, 단계 상태가 메모리에만 있어 이메일/비밀번호 화면으로 되돌아갔다(2026-09-04 사용자 보고). 받은 코드가 무용지물이 되고, 다시 로그인하면 새 코드가 발급돼 앞 코드는 폐기됐다. - 코드를 보낸 시점에 이메일과 발송 시각만 sessionStorage 에 남기고(비밀번호는 저장 안 함), 화면이 다시 뜨면 그 단계로 되돌린다 — 이메일 채움·잠금, 코드 입력칸 노출, 재발송 대기도 발송 시각 기준으로 이어 센다. - 서버 유효시간(5분)이 지난 값은 되살리지 않고 지운다. [이메일/비밀번호 다시 입력]·로그인 성공 시에도 지운다. 세션 저장이 막힌 브라우저면 예전처럼 메모리로만 동작한다. - 서버는 손대지 않았다 — 코드는 이미 DB 에 5분간 있고 검증에 이메일+코드만 쓴다. 검증(공용 브라우저 5174): 대기 상태를 심고 새로고침 → 코드 입력칸 노출·이메일 잠금 채움· 버튼 "인증하고 로그인"·"43초 후 재발송" 복원. [이메일/비밀번호 다시 입력] 누르면 대기 상태 삭제되고 비밀번호 화면 복귀. 발송 6분 전 값은 복원하지 않고 삭제. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,56 @@ import "./A06_Login_UI_Style.css";
|
||||
|
||||
const L = (key: keyof typeof ui_locales): string => ui_locales[key][currentLanguageIndex];
|
||||
|
||||
/* 인증 코드 입력 단계는 **새로고침을 견뎌야 한다**(2026-09-04 사용자 보고).
|
||||
* 휴대폰에서 코드를 보러 메일 앱으로 갔다 오면 브라우저가 탭을 버려 페이지가 다시 뜨는데,
|
||||
* 그때 단계가 메모리에만 있어 이메일/비밀번호 화면으로 되돌아갔다. 서버는 코드를 DB에
|
||||
* 5분간 들고 있고 검증에 이메일+코드만 쓰므로(`/api/auth/login/verify`), 그 사이 단계만
|
||||
* 세션에 남겨 두면 받은 코드를 그대로 넣을 수 있다. 비밀번호는 저장하지 않는다. */
|
||||
const OTP_PENDING_KEY = "a06:otp-pending";
|
||||
/** 서버 OTP 유효시간(분) — `config_system.OTP_VALID_MINUTES` 기본값과 맞춘다. */
|
||||
const OTP_VALID_MS = 5 * 60 * 1000;
|
||||
/** 재발송 대기(초) — 아래 쿨다운과 같은 값. */
|
||||
const RESEND_COOLDOWN_S = 60;
|
||||
|
||||
interface OtpPending {
|
||||
email: string;
|
||||
/** 코드를 보낸 시각(ms). 유효시간·재발송 대기 계산의 기준. */
|
||||
sentAt: number;
|
||||
}
|
||||
|
||||
function readOtpPending(): OtpPending | null {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(OTP_PENDING_KEY);
|
||||
if (!raw) return null;
|
||||
const value = JSON.parse(raw) as OtpPending;
|
||||
if (!value?.email || typeof value.sentAt !== "number") return null;
|
||||
// 유효시간이 지났으면 되살리지 않는다 — 어차피 서버가 거절한다.
|
||||
if (Date.now() - value.sentAt > OTP_VALID_MS) {
|
||||
sessionStorage.removeItem(OTP_PENDING_KEY);
|
||||
return null;
|
||||
}
|
||||
return value;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeOtpPending(email: string): void {
|
||||
try {
|
||||
sessionStorage.setItem(OTP_PENDING_KEY, JSON.stringify({ email, sentAt: Date.now() }));
|
||||
} catch {
|
||||
// 세션 저장이 막힌 브라우저(시크릿 등)에서는 예전처럼 메모리로만 동작한다.
|
||||
}
|
||||
}
|
||||
|
||||
function clearOtpPending(): void {
|
||||
try {
|
||||
sessionStorage.removeItem(OTP_PENDING_KEY);
|
||||
} catch {
|
||||
// 지우기 실패는 무시 — 유효시간이 지나면 어차피 되살리지 않는다.
|
||||
}
|
||||
}
|
||||
|
||||
export function renderA06Login(root: HTMLElement): void {
|
||||
const page = document.createElement("div");
|
||||
page.className = "a06-login";
|
||||
@@ -59,9 +109,10 @@ export function renderA06Login(root: HTMLElement): void {
|
||||
setButtonLabel(resend, L("A06_Login_OtpResend"));
|
||||
}
|
||||
|
||||
function startResendCooldown(): void {
|
||||
function startResendCooldown(remainSeconds = RESEND_COOLDOWN_S): void {
|
||||
stopResendCooldown();
|
||||
resendSeconds = 60;
|
||||
if (remainSeconds <= 0) return;
|
||||
resendSeconds = remainSeconds;
|
||||
resend.disabled = true;
|
||||
const updateLabel = (): void => {
|
||||
setButtonLabel(
|
||||
@@ -80,17 +131,18 @@ export function renderA06Login(root: HTMLElement): void {
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function showOtpStep(): void {
|
||||
function showOtpStep(remainSeconds = RESEND_COOLDOWN_S): void {
|
||||
otpRequired = true;
|
||||
email.input.disabled = true;
|
||||
otp.root.hidden = false;
|
||||
otpActions.hidden = false;
|
||||
password.root.hidden = true;
|
||||
setButtonLabel(submit, L("A06_Login_Verify"));
|
||||
startResendCooldown();
|
||||
startResendCooldown(remainSeconds);
|
||||
}
|
||||
|
||||
function onA06_Login_Back_Click(): void {
|
||||
clearOtpPending();
|
||||
otpRequired = false;
|
||||
email.input.disabled = false;
|
||||
password.root.hidden = false;
|
||||
@@ -107,6 +159,7 @@ export function renderA06Login(root: HTMLElement): void {
|
||||
try {
|
||||
const result = await requestLogin(email.input.value.trim(), password.input.value);
|
||||
if (result.status === "otp_required") {
|
||||
writeOtpPending(email.input.value.trim());
|
||||
showToast(L("A06_Login_OtpSent"), "info");
|
||||
startResendCooldown();
|
||||
} else {
|
||||
@@ -140,9 +193,11 @@ export function renderA06Login(root: HTMLElement): void {
|
||||
? await verifyLogin(emailValue, otp.input.value)
|
||||
: await requestLogin(emailValue, password.input.value);
|
||||
if (result.status === "otp_required") {
|
||||
writeOtpPending(emailValue);
|
||||
showOtpStep();
|
||||
showToast(L("A06_Login_OtpSent"), "info");
|
||||
} else {
|
||||
clearOtpPending();
|
||||
showToast(L("A06_Login_Success"), "success");
|
||||
navigateTo(ROUTES.B01_ACCOUNT);
|
||||
}
|
||||
@@ -163,4 +218,14 @@ export function renderA06Login(root: HTMLElement): void {
|
||||
card.append(title, form, register);
|
||||
page.append(card);
|
||||
root.append(page);
|
||||
|
||||
// 코드를 기다리던 중 화면이 다시 뜬 경우 — 그 단계로 되돌려 준다(이메일은 채워 두고,
|
||||
// 남은 재발송 대기도 보낸 시각 기준으로 이어 센다).
|
||||
const pending = readOtpPending();
|
||||
if (pending) {
|
||||
email.input.value = pending.email;
|
||||
const elapsed = Math.floor((Date.now() - pending.sentAt) / 1000);
|
||||
showOtpStep(Math.max(RESEND_COOLDOWN_S - elapsed, 0));
|
||||
otp.input.focus();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user