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:
2026-09-04 12:56:50 +09:00
co-authored by Claude Opus 5
parent 5df79d2dff
commit e0e9d819c4
+69 -4
View File
@@ -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();
}
}