fix(388-1 · 388-3 보안): 로그아웃 · 계정 바뀜 지움 · 세션 넘침 · 탭 프로젝트 고정

- 로그아웃 = 떠남 확인 먼저 → 서버 로그아웃 → 세션 전부 · 현재 프로젝트 · 3D 지형 지움 → 홈 새로고침
- 계정 바뀜(저장한 주인 ≠ 지금 사용자) = 같은 지움 뒤 새로고침 · 열쇠에 계정 안 섞음
- 세션 한도 넘침 = 결과 칸 비우고 한 번 다시 · 그래도 넘치면 「저장 공간 부족」 안내
- B05 · B06 이 연 프로젝트 고정 — 다른 탭이 바꿔도 이 탭 초안 칸 그대로
- 새 test_384_s_logout_wipe

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmC34fsxxbweEG3RUjs3Mn
This commit is contained in:
2026-10-17 17:21:38 +09:00
co-authored by Claude Opus 5.5
parent 23c6645839
commit 3e312b68d7
8 changed files with 281 additions and 5 deletions
+10 -3
View File
@@ -10,7 +10,7 @@
import { ui_locales, currentLanguageIndex, setLanguage } from "@ui/ui_template_locale";
import { createButton } from "@ui/ui_template_elements";
import { THEME_STORAGE_KEY, LANG_STORAGE_KEY, ROUTES } from "@config/config_frontend";
import { navigateTo, isAuthenticated } from "./router";
import { navigateTo, isAuthenticated, setLeaveGuard, takeLeaveGuard } from "./router";
import { readTouchMode, setTouchMode, type TouchMode } from "./device_context";
const BRAND_ICON_URL = new URL("../resources/app_branding/prog_icon.jpg", import.meta.url).href;
@@ -49,14 +49,21 @@ function onApp_Lang_Toggle_Click(): void {
window.dispatchEvent(new HashChangeEvent("hashchange"));
}
/** 로그아웃(388-1) — 떠남 확인 먼저 → 서버 로그아웃 → 이 브라우저 자료 지움 → 홈으로 새로고침. */
async function onApp_Logout_Click(): Promise<void> {
const guard = takeLeaveGuard();
if (guard && !(await guard())) return; // [취소] — 머묾
setLeaveGuard(null);
const { logout } = await import("../A06_Login/A06_Login_Api_Fetch");
try {
await logout();
} catch {
// 세션이 이미 만료된 경우 등은 무시하고 홈으로 이동
// 세션이 이미 만료된 경우 등은 무시하고 지움 · 홈으로 이동
}
navigateTo(ROUTES.A01_HOME);
const { wipeAccountCache } = await import("./b_project_switch");
await wipeAccountCache();
history.replaceState(null, "", `#/${ROUTES.A01_HOME}`); // hashchange 없이 — 바로 새로고침
window.location.reload();
}
/* -----------------------------------------------------------------------------
+25 -1
View File
@@ -21,6 +21,8 @@
* 않는다** — ②·④는 두 화면이 같은 키를 쓴다.
* ========================================================================== */
import { t } from "@ui/ui_template_locale";
/** 값이 속한 통. `pref`·`draft`·`result` 셋만 저장소를 쓴다(즉시 반영은 표에 없다). */
export type StateBucket = "pref" | "draft" | "result";
@@ -438,13 +440,35 @@ function writeRaw(key: string, value: string | null): void {
try {
const store = storageFor(key);
if (value === null) store.removeItem(key);
else store.setItem(key, value);
else setOrMakeRoom(store, key, value);
} catch {
/* 무시 — 값은 화면 메모리에 남는다. */
}
if (key.startsWith("aislo:pref:")) pushUiPrefs();
}
/** 세션 한도 넘침(388-3) — 결과 칸(다시 셀 수 있음)을 비우고 한 번 다시 · 그래도 넘치면 [저장] 안내. */
function setOrMakeRoom(store: Storage, key: string, value: string): void {
try {
store.setItem(key, value);
return;
} catch (error) {
if (store !== window.sessionStorage || (error as DOMException)?.name !== "QuotaExceededError")
throw error;
}
Object.keys(store)
.filter((stale) => stale.startsWith("aislo:result:") && stale !== key)
.forEach((stale) => writeRaw(stale, null));
try {
store.setItem(key, value);
} catch (error) {
void import("@ui/ui_template_elements").then(({ showToast }) =>
showToast(t("Session_Storage_Full"), "warning", 8000),
);
throw error;
}
}
/** 옛 키에 남은 값을 새 키로 한 번 옮긴다(옮기고 나면 옛 키는 지운다). */
function migrate(
name: StateName,
+32
View File
@@ -8,6 +8,7 @@
* 같은 프로젝트로 다시 들어오면(새로고침 포함) 아무것도 안 지운다(탭마다 들어온 표식).
* ========================================================================== */
import { CURRENT_PROJECT_ID_KEY } from "@config/config_frontend";
import { showConfirmDialog } from "@ui/ui_template_elements";
import { purgeOtherProjects } from "./b_asset_cache";
import { STATE_REGISTRY, type StateEntry } from "./b_page_state";
@@ -120,3 +121,34 @@ export async function enterProject(
/* 저장소가 막힌 브라우저 — 캐시를 못 지워도 프로젝트는 연다. */
}
}
/** 이 브라우저에 자료를 남긴 계정(388-1) — 열쇠에는 안 섞고 바뀜 판정에만. */
const OWNER_KEY = "aislo:owner";
/**
* 계정이 떠남 · 바뀜(388-1) — 이 탭 세션 전부(초안 · 결과 · 들어온 표식) · 현재 프로젝트 ·
* 3D 지형 보관함을 지운다. 계정 취향(localStorage pref)은 남긴다. 부른 쪽이 새로고침해 모듈 메모리도 버림.
*/
export async function wipeAccountCache(): Promise<void> {
try {
window.sessionStorage.clear();
window.localStorage.removeItem(CURRENT_PROJECT_ID_KEY);
} catch {
/* 저장소가 막힌 브라우저 — 지울 것이 없다. */
}
await purgeOtherProjects(""); // 빈 id 보다 큰 열쇠 = 모든 프로젝트
}
/** 로그인 확인 때 — 앞서 자료를 남긴 계정과 다르면 지우고 true(부른 쪽이 새로고침). */
export async function wipeIfOwnerChanged(userId: string): Promise<boolean> {
let previous: string | null = null;
try {
previous = window.localStorage.getItem(OWNER_KEY);
window.localStorage.setItem(OWNER_KEY, userId);
} catch {
return false;
}
if (previous === userId) return false;
await wipeAccountCache();
return true;
}
+2
View File
@@ -31,6 +31,7 @@ import type { DesignView } from "./design_dock";
import { saveSharedDesign, type DesignPageShared } from "./design_shared";
import { guardDesignLeave } from "./design_leave";
import { usePipeIds } from "./design_pipe_ids";
import { pinDraftProject } from "./design_store";
import { setRouteShiftOpener } from "../../B06_Section/B06_Section_UI_Cross_Center";
import { reconcilePagePipeline } from "./design_pipeline_page";
import "./design_page.css";
@@ -109,6 +110,7 @@ export function renderDesignPage(root: HTMLElement): MountedPage {
page.className = "design-page";
root.replaceChildren(page);
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? "";
pinDraftProject(projectId); // 연 프로젝트 고정(388-3) — 다른 탭이 바꿔도 이 탭 초안 칸 그대로
usePipeIds(projectId || null); // 관 세션 id 장부 = 이 프로젝트(323-5 · 카드 · 알약 · 종단 · 3D 같은 id)
// 한 벌 틀 — 좌측 자리 · 가운데(위 칸 + 아래 종단도). 진행 단계 틀은 단계 상태가 오면 감싼다.
+8 -1
View File
@@ -76,10 +76,17 @@ export interface DesignStore {
/** 옛 계획고 초안 키 — 위 머리 주석 참조(남은 옛 값을 쓸어 낼 때만 쓴다). */
export const ALIGNMENT_DRAFT_KEY_PREFIX = "b05-profile-alignment-draft";
/** 계획고 초안 칸의 프로젝트 — 부르는 쪽이 모르면 지금 연 프로젝트, 그것도 없으면 `-`
/** 이 탭 페이지가 연 프로젝트(388-3) — 다른 탭이 현재 프로젝트를 바꿔도 이 탭 초안이 안 섞임. */
let openedProjectId: string | null = null;
export function pinDraftProject(projectId: string | null): void {
openedProjectId = projectId || null;
}
/** 계획고 초안 칸의 프로젝트 — 부르는 쪽이 모르면 이 탭이 연 프로젝트, 그것도 없으면 `-`
* (노선 번호는 프로젝트를 건너 겹치지 않는다). */
export function draftProjectId(projectId: string | null = null): string {
if (projectId) return projectId;
if (openedProjectId) return openedProjectId;
try {
return window.localStorage.getItem(CURRENT_PROJECT_ID_KEY) || "-";
} catch {
+3
View File
@@ -1,5 +1,6 @@
import { API_BASE_URL } from "@config/config_frontend";
import { syncUiPrefs } from "../A00_Common/b_page_state";
import { wipeIfOwnerChanged } from "../A00_Common/b_project_switch";
export interface ApiResult {
status: string;
@@ -61,6 +62,8 @@ function sessionOnce(): Promise<SessionUser | null> {
const response = await fetch(`${API_BASE_URL}/auth/session`, { credentials: "include" });
if (!response.ok) return null;
const data = (await response.json()) as { status: string; user?: SessionUser };
// 같은 탭 다른 계정 — 앞 계정 초안 · 결과 · 현재 프로젝트 · 지형을 지우고 새로 엶(388-1)
if (data.user && (await wipeIfOwnerChanged(data.user.id))) window.location.reload();
// 로그인이 확인된 첫 순간에 계정 취향을 한 번 받아 온다 — PC 를 바꿔도 같은 배치가
// 되게(2026-09-07). 실패해도 화면은 브라우저에 남은 값으로 그대로 돈다.
if (data.user && !uiPrefsSynced) {
+128
View File
@@ -0,0 +1,128 @@
/* 388 보안 — 진짜 TS(b_page_state · b_project_switch · design_store)를 가짜 저장소 위에서 돌려
* JSON 으로 냄: 로그아웃 지움 · 계정 바뀜 지움 · 세션 넘침 · 탭이 연 프로젝트 고정. */
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const ROOT = path.join(__dirname, "..", "..");
const load = (rel, requireStub) => {
const js = ts.transpileModule(fs.readFileSync(path.join(ROOT, rel), "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const exports = {};
new Function("exports", "require", "window", js)(exports, requireStub, global.window);
return exports;
};
/** 진짜 Storage 처럼 Object.keys 가 열쇠를 냄 · limit 글자 넘으면 QuotaExceededError. */
function fakeStorage(limit = Infinity) {
const data = {};
const size = () => Object.entries(data).reduce((n, [k, v]) => n + k.length + v.length, 0);
const api = {
length: { get: () => Object.keys(data).length },
key: { value: (i) => Object.keys(data)[i] ?? null },
getItem: { value: (k) => (k in data ? data[k] : null) },
setItem: {
value: (k, v) => {
const old = data[k];
data[k] = String(v);
if (size() > limit) {
if (old === undefined) delete data[k];
else data[k] = old;
const error = new Error("full");
error.name = "QuotaExceededError";
throw error;
}
},
},
removeItem: { value: (k) => delete data[k] },
clear: { value: () => Object.keys(data).forEach((k) => delete data[k]) },
};
return new Proxy(data, {
get: (t, p) => (p in api ? (api[p].get ? api[p].get() : api[p].value) : t[p]),
});
}
global.window = { sessionStorage: fakeStorage(), localStorage: fakeStorage() };
const toasts = [];
const pageState = load("A00_Common/b_page_state.ts", (id) =>
id.includes("ui_template_locale")
? { t: (key) => key }
: { showToast: (msg, kind) => toasts.push([msg, kind]) },
);
const purged = [];
const config = { CURRENT_PROJECT_ID_KEY: "frd_current_project_id" };
const sw = load("A00_Common/b_project_switch.ts", (id) => {
if (id.includes("b_page_state")) return pageState;
if (id.includes("b_asset_cache")) return { purgeOtherProjects: async (p) => purged.push(p) };
if (id.includes("config_frontend")) return config;
return { showConfirmDialog: async () => true };
});
const store = load("A00_Common/design/design_store.ts", (id) =>
id.includes("b_page_state") ? pageState : config,
);
const P = "aaaa1111";
const seed = () => {
global.window.sessionStorage = fakeStorage();
const s = global.window.sessionStorage;
s.setItem(`aislo:draft:structures:${P}`, '[{"id":1}]');
s.setItem(`aislo:result:section-detail:${P}:7`, '{"d":1}');
s.setItem("aislo:entered-project", P);
s.setItem("b05:pipes", "[1]");
global.window.localStorage.setItem("frd_current_project_id", P);
global.window.localStorage.setItem("aislo:pref:panel:x", "1");
purged.length = 0;
};
const sessionKeys = () => Object.keys(global.window.sessionStorage).sort();
const local = (k) => global.window.localStorage.getItem(k);
const tick = () => new Promise((r) => setTimeout(r, 0));
(async () => {
const out = {};
seed();
await sw.wipeAccountCache();
out.logout = {
session: sessionKeys(),
project: local("frd_current_project_id"),
pref: local("aislo:pref:panel:x"),
purged: [...purged],
};
global.window.localStorage.removeItem("aislo:owner");
seed();
out.owner = { first: await sw.wipeIfOwnerChanged("u1"), firstKeys: sessionKeys().length };
seed();
out.owner.same = await sw.wipeIfOwnerChanged("u1");
out.owner.sameKeys = sessionKeys().length;
out.owner.other = await sw.wipeIfOwnerChanged("u2");
out.owner.otherKeys = sessionKeys().length;
out.owner.otherProject = local("frd_current_project_id");
out.owner.stored = local("aislo:owner");
// 세션 넘침 — 결과 칸 비우고 다시 · 그래도 넘치면 안내
global.window.sessionStorage = fakeStorage(400);
const s = global.window.sessionStorage;
s.setItem(`aislo:result:section-detail:${P}:7`, "r".repeat(200));
s.setItem(`aislo:draft:structures:${P}`, "d".repeat(50));
pageState.writeByKey(`aislo:draft:crossw:${P}:7`, "c".repeat(100));
out.overflow = {
saved: s.getItem(`aislo:draft:crossw:${P}:7`)?.length ?? 0,
result: s.getItem(`aislo:result:section-detail:${P}:7`),
draft: s.getItem(`aislo:draft:structures:${P}`)?.length ?? 0,
toasts: toasts.length,
};
pageState.writeByKey(`aislo:draft:crossw:${P}:8`, "c".repeat(500));
await tick();
out.full = { saved: s.getItem(`aislo:draft:crossw:${P}:8`), toasts: [...toasts] };
// 탭이 연 프로젝트 고정 — 다른 탭이 현재 프로젝트를 바꿔도 그대로
global.window.localStorage.setItem("frd_current_project_id", "first");
out.pin = { before: store.draftProjectId() };
store.pinDraftProject("first");
global.window.localStorage.setItem("frd_current_project_id", "other-tab");
out.pin.after = store.draftProjectId();
out.pin.explicit = store.draftProjectId("given");
process.stdout.write(JSON.stringify(out));
})();
@@ -0,0 +1,73 @@
"""388-1 · 388-3 보안 — 로그아웃 · 계정 바뀜 지움 · 세션 넘침 · 탭이 연 프로젝트 고정(요구 242).
- 로그아웃 = 세션 전부(초안 · 결과 · 들어온 표식) · 현재 프로젝트 · 3D 보관함 지움 · pref 둠
- 계정 바뀜(저장한 주인 ≠ 지금 사용자) = 같은 지움 · 같은 계정이면 안 지움 · 열쇠에 계정 안 섞음
- 세션 넘침 = 결과 칸 비우고 한 번 다시 · 그래도 넘치면 「저장 공간 부족」 안내
- 탭이 연 프로젝트 고정 = 다른 탭이 현재 프로젝트를 바꿔도 이 탭 초안 칸 그대로
`helper_384_s_session.cjs` 가 진짜 TS 를 가짜 저장소 위에서 돌림.
"""
import json
import os
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
HERE = os.path.dirname(os.path.abspath(__file__))
@pytest.fixture(scope="module")
def out():
proc = subprocess.run(
["node", os.path.join(HERE, "helper_384_s_session.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_로그아웃_지움(out):
assert out["logout"]["session"] == []
assert out["logout"]["project"] is None
assert out["logout"]["pref"] == "1"
assert out["logout"]["purged"] == [""] # 빈 id = 모든 프로젝트 지형
def test_계정_바뀜_지움(out):
owner = out["owner"]
assert owner["first"] is True and owner["firstKeys"] == 0 # 주인 모름 = 지움
assert owner["same"] is False and owner["sameKeys"] == 4
assert owner["other"] is True and owner["otherKeys"] == 0
assert owner["otherProject"] is None and owner["stored"] == "u2"
def test_세션_넘침_결과_비우고_다시(out):
assert out["overflow"] == {"saved": 100, "result": None, "draft": 50, "toasts": 0}
def test_그래도_넘치면_안내(out):
assert out["full"]["saved"] is None
assert out["full"]["toasts"] == [["Session_Storage_Full", "warning"]]
def test_탭이_연_프로젝트_고정(out):
assert out["pin"] == {"before": "first", "after": "first", "explicit": "given"}
def test_로그아웃_길_차례():
shell = (ROOT / "A00_Common/app_shell.ts").read_text(encoding="utf-8")
body = shell[shell.index("async function onApp_Logout_Click") :]
body = body[: body.index("\n}\n")]
order = [
body.index(s) for s in ("takeLeaveGuard()", "logout()", "wipeAccountCache()", "reload()")
]
assert order == sorted(order)
login = (ROOT / "A06_Login/A06_Login_Api_Fetch.ts").read_text(encoding="utf-8")
assert "wipeIfOwnerChanged(data.user.id)" in login
page = (ROOT / "A00_Common/design/design_page.ts").read_text(encoding="utf-8")
assert "pinDraftProject(projectId)" in page