From 049b914916dbfd23b3523cae05372af3ca07c56e Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Mon, 4 May 2026 23:23:40 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A4=D0=A3=D0=9D=D0=9A=D0=A6=D0=98=D0=98=20-?= =?UTF-8?q?=20NODEDC=20LAUNCHER:=20sync=20logout=20across=20tabs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/dev-server.mjs | 109 ++++++++++++++++++++++++++++++ src/app/LauncherApp.tsx | 18 ++++- src/shared/session/sessionSync.ts | 93 +++++++++++++++++++++++++ 3 files changed, 219 insertions(+), 1 deletion(-) create mode 100644 src/shared/session/sessionSync.ts diff --git a/server/dev-server.mjs b/server/dev-server.mjs index ae7a7ae..e549b39 100644 --- a/server/dev-server.mjs +++ b/server/dev-server.mjs @@ -145,6 +145,16 @@ app.get("/auth/logged-out", (req, res) => { res.redirect(buildLoginRedirectUrl(returnTo, { forceLogin: true })); }); +app.get("/auth/session-sync", (req, res) => { + const allowedOrigins = getSessionSyncAllowedOrigins(); + setNoStore(res); + res.setHeader( + "Content-Security-Policy", + `default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; frame-ancestors ${allowedOrigins.join(" ")}` + ); + res.type("html").send(renderSessionSyncBridgePage(allowedOrigins)); +}); + app.get("/auth/logout", asyncRoute(async (req, res) => { const session = getCurrentSession(req); const returnTo = sanitizeReturnTo(req.query.returnTo); @@ -954,6 +964,20 @@ function renderGlobalLogoutPage(frontchannelLogoutUrls, finalRedirectUrl) {

Закрываем сессии подключённых приложений и платформенный вход.

+ +`; +} + +function getSessionSyncAllowedOrigins() { + const origins = new Set([new URL(config.appBaseUrl).origin]); + + for (const logoutUrl of getFrontchannelLogoutUrls()) { + try { + origins.add(new URL(logoutUrl).origin); + } catch { + void 0; + } + } + + return [...origins]; +} + function readLauncherData() { const dataPath = join(projectRoot, "public", "storage", "launcher-data.json"); diff --git a/src/app/LauncherApp.tsx b/src/app/LauncherApp.tsx index c69429f..8a88a64 100644 --- a/src/app/LauncherApp.tsx +++ b/src/app/LauncherApp.tsx @@ -42,6 +42,7 @@ import { type LauncherAuthApp, } from "../shared/api/authApi"; import { updateOwnPassword, updateOwnProfile } from "../shared/api/profileApi"; +import { subscribeToNodeDCLogoutEvents } from "../shared/session/sessionSync"; import { loadPersistedLauncherData } from "../shared/api/storageApi"; import { AdminOverlay, @@ -190,6 +191,17 @@ export function LauncherApp() { window.location.replace(buildLoginRedirectUrl(authSession.loginUrl)); }, [authSession]); + useEffect(() => { + let isRedirecting = false; + + return subscribeToNodeDCLogoutEvents(() => { + if (isRedirecting) return; + + isRedirecting = true; + window.location.replace(buildLoginRedirectUrl("/auth/login?prompt=login")); + }); + }, []); + useEffect(() => { let isMounted = true; @@ -511,6 +523,10 @@ export function LauncherApp() { return null; } + const handleLogout = () => { + window.location.replace(authSession.logoutUrl); + }; + return (
setAdminOpen((current) => !current)} onOpenShowcase={() => setAdminOpen(false)} onOpenProfileSettings={() => setProfileSettingsOpen(true)} - onLogout={() => window.location.replace(authSession.logoutUrl)} + onLogout={handleLogout} />
diff --git a/src/shared/session/sessionSync.ts b/src/shared/session/sessionSync.ts new file mode 100644 index 0000000..73364f1 --- /dev/null +++ b/src/shared/session/sessionSync.ts @@ -0,0 +1,93 @@ +export const NODEDC_SESSION_EVENT_TYPE = "nodedc:session:logout"; +export const NODEDC_SESSION_CHANNEL_NAME = "nodedc-platform-session"; +export const NODEDC_SESSION_STORAGE_KEY = "nodedc:platform-session-event"; + +export interface NodeDCSessionEvent { + type: typeof NODEDC_SESSION_EVENT_TYPE; + id: string; + source: string; + createdAt: number; +} + +export function publishNodeDCLogoutEvent(source = "launcher") { + if (typeof window === "undefined") return; + + const event: NodeDCSessionEvent = { + type: NODEDC_SESSION_EVENT_TYPE, + id: createEventId(), + source, + createdAt: Date.now(), + }; + + try { + const channel = new BroadcastChannel(NODEDC_SESSION_CHANNEL_NAME); + channel.postMessage(event); + channel.close(); + } catch { + void 0; + } + + try { + window.localStorage.setItem(NODEDC_SESSION_STORAGE_KEY, JSON.stringify(event)); + } catch { + void 0; + } +} + +export function subscribeToNodeDCLogoutEvents(onLogout: (event: NodeDCSessionEvent) => void) { + if (typeof window === "undefined") return () => undefined; + + let lastEventId: string | null = null; + let channel: BroadcastChannel | null = null; + + const handleEvent = (payload: unknown) => { + if (!isNodeDCSessionEvent(payload) || payload.id === lastEventId) return; + + lastEventId = payload.id; + onLogout(payload); + }; + + try { + channel = new BroadcastChannel(NODEDC_SESSION_CHANNEL_NAME); + channel.addEventListener("message", (event) => handleEvent(event.data)); + } catch { + channel = null; + } + + const handleStorage = (event: StorageEvent) => { + if (event.key !== NODEDC_SESSION_STORAGE_KEY || !event.newValue) return; + + try { + handleEvent(JSON.parse(event.newValue)); + } catch { + void 0; + } + }; + + window.addEventListener("storage", handleStorage); + + return () => { + channel?.close(); + window.removeEventListener("storage", handleStorage); + }; +} + +function isNodeDCSessionEvent(value: unknown): value is NodeDCSessionEvent { + if (!value || typeof value !== "object") return false; + + const event = value as Partial; + return ( + event.type === NODEDC_SESSION_EVENT_TYPE && + typeof event.id === "string" && + typeof event.source === "string" && + typeof event.createdAt === "number" + ); +} + +function createEventId() { + try { + return crypto.randomUUID(); + } catch { + return `${Date.now()}-${Math.random().toString(36).slice(2)}`; + } +}