--- orig/index.html 2026-11-02 17:23:04.656144030 +0900 +++ work/index.html 2026-11-02 17:32:55.389161247 +0900 @@ -911,6 +911,7 @@ const inFlightLocks = { like: new Set(), comment: false, + report: false, submit: false }; @@ -1236,6 +1237,24 @@ } } + // 서버에 관리자 세션 종료를 알려 토큰을 즉시 무효화한다 (실패해도 로컬 로그아웃은 계속 진행). + function revokeAdminTokenOnServer(token) { + if (!token) return; + try { + fetch(GAS_ENDPOINT_URL, { + method: "POST", + headers: { "Content-Type": "text/plain;charset=utf-8" }, + body: JSON.stringify({ action: "logout_admin", adminToken: token }), + keepalive: true + }).catch(() => {}); + } catch (e) {} + } + + // PIN 오류로 인한 실패인지 판별 (통신 오류·점검·서버 바쁨 등은 시도 횟수에 포함하지 않는다) + function isPinFailure(result) { + return Boolean(result) && result.status === "error" && result.code === "PIN_INVALID"; + } + function handleAdminAuthExpired(msg) { isAdmin = false; adminTokenSession = ""; @@ -1249,6 +1268,7 @@ function logoutAdminMode() { if (!confirm("관리자 모드를 종료하시겠습니까?")) return; + revokeAdminTokenOnServer(adminTokenSession); isAdmin = false; adminTokenSession = ""; adminPreviewBypass = false; @@ -1476,16 +1496,32 @@ if (!forceFullRefresh && !isManual && currentDataVersion) { params.set("clientVersion", currentDataVersion); } + let res; if (isAdmin && adminTokenSession) { - params.set("adminToken", adminTokenSession); + // 관리자 토큰은 URL(GET)에 싣지 않고 POST 본문으로만 전달한다. + res = await fetch(GAS_ENDPOINT_URL, { + method: "POST", + headers: { "Content-Type": "text/plain;charset=utf-8" }, + body: JSON.stringify({ + action: "admin_list", + adminToken: adminTokenSession, + googleIdToken: googleIdTokenSession + }) + }); + } else { + const queryStr = params.toString(); + const url = queryStr ? `${GAS_ENDPOINT_URL}?${queryStr}` : GAS_ENDPOINT_URL; + res = await fetch(url); } - - const queryStr = params.toString(); - const url = queryStr ? `${GAS_ENDPOINT_URL}?${queryStr}` : GAS_ENDPOINT_URL; - - const res = await fetch(url); const result = await res.json(); + if (result.status === "auth_expired") { + handleAdminAuthExpired(result.message); + // 공개 목록으로 한 번만 다시 조회한다 (isAdmin 이 해제되었으므로 무한 반복되지 않음) + if (!isAdmin) await fetchAgendas(isInitial, false, true); + return; + } + if (result.status === "not_modified") { isServerStateVerified = true; isServerUnreachable = false; @@ -1527,6 +1563,9 @@ renderDetailContent(currentDetailId); } if (isManual) showToast("최신 안건 데이터로 동기화되었습니다."); + } else { + // success / not_modified 가 아닌 응답(error 등)은 통신 실패와 동일하게 처리 + throw new Error(result.message || "unexpected response"); } } catch (err) { if (!isServerStateVerified) { @@ -2038,7 +2077,7 @@ const target = agendas.find(a => String(a.id) === strId); if (target) { target.likes = Number(result.newLikes) || 0; - target.status = normalizeAgendaStatus(target.status, target.likes); + target.status = normalizeAgendaStatus(result.agendaStatus || target.status, target.likes); } renderAgendas(); if (currentDetailId === strId) renderDetailContent(strId); @@ -2166,10 +2205,18 @@ showToast(verifyResult.message || "비밀번호 5회 오류로 10분간 잠금되었습니다.", "warn"); return; } - if (verifyResult.status === "error") { - const lockState = recordClientPinAttempt(safeId, false); - const remain = Math.max(0, PIN_MAX_ATTEMPTS - (lockState?.count || 0)); - showToast(verifyResult.message || `비밀번호가 일치하지 않습니다. (남은 시도: ${remain}회)`, "warn"); + if (verifyResult.status === "auth_expired") { + handleAdminAuthExpired(verifyResult.message); + return; + } + if (verifyResult.status !== "success") { + if (isPinFailure(verifyResult)) { + const lockState = recordClientPinAttempt(safeId, false); + const remain = Math.max(0, PIN_MAX_ATTEMPTS - (lockState?.count || 0)); + showToast(verifyResult.message || `비밀번호가 일치하지 않습니다. (남은 시도: ${remain}회)`, "warn"); + } else { + showToast(verifyResult.message || "비밀번호 확인에 실패했습니다. 잠시 후 다시 시도해 주세요.", "warn"); + } return; } recordClientPinAttempt(safeId, true); @@ -2274,12 +2321,16 @@ showToast("안건이 정상적으로 삭제되었습니다."); navigateHome(); fetchAgendas(false, false, true); + } else if (result.status === "auth_expired") { + handleAdminAuthExpired(result.message); } else if (result.status === "locked") { showToast(result.message || "비밀번호 5회 오류로 10분간 재시도가 제한됩니다.", "warn"); - } else { + } else if (isPinFailure(result)) { const lockState = recordClientPinAttempt(safeId, false); const remain = Math.max(0, PIN_MAX_ATTEMPTS - (lockState?.count || 0)); showToast(result.message || `비밀번호가 일치하지 않습니다. (남은 시도: ${remain}회)`, "warn"); + } else { + showToast(result.message || "삭제 처리에 실패했습니다. 잠시 후 다시 시도해 주세요.", "warn"); } } catch (e) { showToast("통신 오류가 발생했습니다.", "warn"); @@ -2333,10 +2384,11 @@ } async function submitReport() { - if (!currentDetailId) return; + if (!currentDetailId || inFlightLocks.report) return; if (isUserActionBlockedByMaintenance()) return; const reason = document.getElementById("reportReason").value; closeReportModal(); + inFlightLocks.report = true; try { const res = await fetch(GAS_ENDPOINT_URL, { @@ -2356,10 +2408,16 @@ showToast(result.message || "현재 시스템 점검 중입니다.", "warn"); return; } - showToast("신고가 접수되어 기획단 검토 대상에 등록되었습니다."); - fetchAgendas(false, false, true); + if (result.status === "success") { + showToast("신고가 접수되어 기획단 검토 대상에 등록되었습니다."); + fetchAgendas(false, false, true); + } else { + showToast(result.message || "신고 접수에 실패했습니다. 잠시 후 다시 시도해 주세요.", "warn"); + } } catch (e) { showToast("통신 오류가 발생했습니다.", "warn"); + } finally { + inFlightLocks.report = false; } } @@ -2721,11 +2779,14 @@ await fetchAgendas(false, false, true); if (currentDetailId === editingId) renderDetailContent(editingId); showToast("안건 내용이 수정되었습니다."); + } else if (result.status === "auth_expired") { + closeProposalModal(); + handleAdminAuthExpired(result.message); } else if (result.status === "locked") { showToast(result.message || "비밀번호 5회 오류로 10분간 잠금되었습니다.", "warn"); } else { - recordClientPinAttempt(editingId, false); - showToast(result.message || "수정 권한이 없습니다.", "warn"); + if (isPinFailure(result)) recordClientPinAttempt(editingId, false); + showToast(result.message || "수정에 실패했습니다. 잠시 후 다시 시도해 주세요.", "warn"); } } catch (err) { showToast("서버 통신 오류가 발생했습니다.", "warn"); @@ -2911,6 +2972,7 @@ sessionStorage.removeItem("iwoo_google_id_token"); if (isAdmin) { + revokeAdminTokenOnServer(adminTokenSession); isAdmin = false; adminTokenSession = ""; adminPreviewBypass = false;