From e0e9d819c4d6a5cbf030066068dc398c98104673 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 4 Sep 2026 12:56:50 +0900 Subject: [PATCH] =?UTF-8?q?fix(A06):=20=EC=9D=B8=EC=A6=9D=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EB=8B=A8=EA=B3=84=EA=B0=80=20=EC=83=88=EB=A1=9C?= =?UTF-8?q?=EA=B3=A0=EC=B9=A8=EC=97=90=20=EC=82=AC=EB=9D=BC=EC=A7=80?= =?UTF-8?q?=EB=8D=98=20=EB=AC=B8=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 휴대폰에서 코드를 보러 메일 앱에 다녀오면 브라우저가 탭을 버려 페이지가 다시 뜨는데, 단계 상태가 메모리에만 있어 이메일/비밀번호 화면으로 되돌아갔다(2026-09-04 사용자 보고). 받은 코드가 무용지물이 되고, 다시 로그인하면 새 코드가 발급돼 앞 코드는 폐기됐다. - 코드를 보낸 시점에 이메일과 발송 시각만 sessionStorage 에 남기고(비밀번호는 저장 안 함), 화면이 다시 뜨면 그 단계로 되돌린다 — 이메일 채움·잠금, 코드 입력칸 노출, 재발송 대기도 발송 시각 기준으로 이어 센다. - 서버 유효시간(5분)이 지난 값은 되살리지 않고 지운다. [이메일/비밀번호 다시 입력]·로그인 성공 시에도 지운다. 세션 저장이 막힌 브라우저면 예전처럼 메모리로만 동작한다. - 서버는 손대지 않았다 — 코드는 이미 DB 에 5분간 있고 검증에 이메일+코드만 쓴다. 검증(공용 브라우저 5174): 대기 상태를 심고 새로고침 → 코드 입력칸 노출·이메일 잠금 채움· 버튼 "인증하고 로그인"·"43초 후 재발송" 복원. [이메일/비밀번호 다시 입력] 누르면 대기 상태 삭제되고 비밀번호 화면 복귀. 발송 6분 전 값은 복원하지 않고 삭제. Co-Authored-By: Claude Opus 5 (1M context) --- A06_Login/A06_Login_UI_Auth_Page.ts | 73 +++++++++++++++++++++++++++-- 1 file changed, 69 insertions(+), 4 deletions(-) diff --git a/A06_Login/A06_Login_UI_Auth_Page.ts b/A06_Login/A06_Login_UI_Auth_Page.ts index 2b5f9e9a..45e92a9a 100644 --- a/A06_Login/A06_Login_UI_Auth_Page.ts +++ b/A06_Login/A06_Login_UI_Auth_Page.ts @@ -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(); + } }