diff --git a/A00_Common/app_shell.ts b/A00_Common/app_shell.ts index c086b2f8c..6799c8064 100644 --- a/A00_Common/app_shell.ts +++ b/A00_Common/app_shell.ts @@ -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 { + 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(); } /* ----------------------------------------------------------------------------- diff --git a/A00_Common/b_page_state.ts b/A00_Common/b_page_state.ts index 60ee6b9dd..de543f245 100644 --- a/A00_Common/b_page_state.ts +++ b/A00_Common/b_page_state.ts @@ -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, diff --git a/A00_Common/b_project_switch.ts b/A00_Common/b_project_switch.ts index 3ffaef671..616cd37d4 100644 --- a/A00_Common/b_project_switch.ts +++ b/A00_Common/b_project_switch.ts @@ -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 { + try { + window.sessionStorage.clear(); + window.localStorage.removeItem(CURRENT_PROJECT_ID_KEY); + } catch { + /* 저장소가 막힌 브라우저 — 지울 것이 없다. */ + } + await purgeOtherProjects(""); // 빈 id 보다 큰 열쇠 = 모든 프로젝트 +} + +/** 로그인 확인 때 — 앞서 자료를 남긴 계정과 다르면 지우고 true(부른 쪽이 새로고침). */ +export async function wipeIfOwnerChanged(userId: string): Promise { + 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; +} diff --git a/A00_Common/design/design_page.ts b/A00_Common/design/design_page.ts index dbd6e2dcc..b655f1da9 100644 --- a/A00_Common/design/design_page.ts +++ b/A00_Common/design/design_page.ts @@ -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) // 한 벌 틀 — 좌측 자리 · 가운데(위 칸 + 아래 종단도). 진행 단계 틀은 단계 상태가 오면 감싼다. diff --git a/A00_Common/design/design_store.ts b/A00_Common/design/design_store.ts index 03742a1fa..706312767 100644 --- a/A00_Common/design/design_store.ts +++ b/A00_Common/design/design_store.ts @@ -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 { diff --git a/A06_Login/A06_Login_Api_Fetch.ts b/A06_Login/A06_Login_Api_Fetch.ts index 157cb1290..66532ac0e 100644 --- a/A06_Login/A06_Login_Api_Fetch.ts +++ b/A06_Login/A06_Login_Api_Fetch.ts @@ -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 { 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) { diff --git a/resources/tester/helper_384_s_session.cjs b/resources/tester/helper_384_s_session.cjs new file mode 100644 index 000000000..8f988a40e --- /dev/null +++ b/resources/tester/helper_384_s_session.cjs @@ -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)); +})(); diff --git a/resources/tester/test_384_s_logout_wipe.py b/resources/tester/test_384_s_logout_wipe.py new file mode 100644 index 000000000..1d29b37c6 --- /dev/null +++ b/resources/tester/test_384_s_logout_wipe.py @@ -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