--- 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;