diff --git a/frontend/dcViewer.js b/frontend/dcViewer.js index 37acfe3..8420eba 100644 --- a/frontend/dcViewer.js +++ b/frontend/dcViewer.js @@ -447,9 +447,10 @@ const configureCameraControl = () => { if (!control) return; activeZoomSpeed = normalizeZoomSpeed(activeZoomSpeed); configureCameraPanMap(control); - control.followPointer = true; + // Keep wheel dolly speed independent from whether the pointer is over model geometry. + control.followPointer = false; control.doublePickFlyTo = false; - control.smartPivot = true; + control.smartPivot = false; control.zoomOnMouseWheel = true; control.keyboardDollyRate = CAMERA_CONTROL_SETTINGS.keyboardDollyRate; control.mouseWheelDollyRate = activeZoomSpeed; @@ -684,6 +685,8 @@ let nodeDCLogoutHandling = false; let nodeDCLoginHandling = false; let nodeDCSessionWatchTimer = null; let nodeDCSessionWatchInFlight = false; +let nodeDCLoginSyncPrimed = false; +let nodeDCLoginSyncEventId = ""; const handleNodeDCSessionLogout = async () => { if (nodeDCLogoutHandling) return; @@ -844,9 +847,24 @@ const scheduleShareSessionPromotion = (shareToken) => { document.body.appendChild(iframe); }; +const getLoginSyncEventId = (sessionPayload) => ( + typeof sessionPayload?.loginSyncEventId === "string" ? sessionPayload.loginSyncEventId : "" +); + const applyLiveSessionPayload = async (sessionPayload, { reason = "watch" } = {}) => { const wasAuthenticated = runtimeSessionInfo.authenticated === true; const isAuthenticated = sessionPayload?.authenticated === true; + const loginSyncEventId = getLoginSyncEventId(sessionPayload); + const hasNewLoginSyncEvent = + nodeDCLoginSyncPrimed && + !isAuthenticated && + !!loginSyncEventId && + loginSyncEventId !== nodeDCLoginSyncEventId; + + nodeDCLoginSyncPrimed = true; + if (loginSyncEventId) { + nodeDCLoginSyncEventId = loginSyncEventId; + } runtimeSessionInfo = { ...runtimeSessionInfo, @@ -861,6 +879,10 @@ const applyLiveSessionPayload = async (sessionPayload, { reason = "watch" } = {} await handleNodeDCSessionLogout(); return; } + if (hasNewLoginSyncEvent) { + handleNodeDCSessionLogin(); + return; + } const shareToken = getShareTokenFromPath(); if (shareToken && viewerMode === "guest") { scheduleShareSessionPromotion(shareToken); diff --git a/server/index.js b/server/index.js index 382042d..ee7d4d5 100644 --- a/server/index.js +++ b/server/index.js @@ -56,6 +56,7 @@ const BIM_SESSION_COOKIE = process.env.NODEDC_BIM_SESSION_COOKIE || "nodedc_bim_ const BIM_HANDOFF_COOKIE = process.env.NODEDC_BIM_HANDOFF_COOKIE || "nodedc_bim_handoff"; const BIM_SESSION_TTL_MS = Number(process.env.NODEDC_BIM_SESSION_TTL_MS || 12 * 60 * 60 * 1000); const BIM_HANDOFF_TTL_SECONDS = Number(process.env.NODEDC_BIM_HANDOFF_TTL_SECONDS || 60); +const BIM_LOGIN_SYNC_TTL_MS = Number(process.env.NODEDC_BIM_LOGIN_SYNC_TTL_MS || 60 * 1000); const BIM_COOKIE_SECURE = parseBooleanEnv(process.env.NODEDC_BIM_COOKIE_SECURE, false); const BIM_COOKIE_SAMESITE = process.env.NODEDC_BIM_COOKIE_SAMESITE || (BIM_COOKIE_SECURE ? "None" : "Lax"); const SHARE_AUTH_GUEST_PARAM = "ndc_bim_guest"; @@ -64,6 +65,7 @@ const CORS_ALLOWED_ORIGINS = String(process.env.NODEDC_BIM_ALLOWED_ORIGINS || "" .map((origin) => origin.trim()) .filter(Boolean); const bimSessions = new Map(); +let latestBimLoginSyncEvent = null; const MIME_TYPES = { ".html": "text/html", @@ -721,6 +723,27 @@ const parseJSONBody = async (req) => { const randomBase64Url = (bytes = 24) => crypto.randomBytes(bytes).toString("base64url"); +const markBimLoginSyncEvent = () => { + const ttlMs = Number.isFinite(BIM_LOGIN_SYNC_TTL_MS) ? Math.max(1000, BIM_LOGIN_SYNC_TTL_MS) : 60 * 1000; + latestBimLoginSyncEvent = { + id: randomBase64Url(16), + createdAt: nowIso(), + expiresAt: Date.now() + ttlMs + }; + return latestBimLoginSyncEvent; +}; + +const getActiveBimLoginSyncEvent = () => { + if (!latestBimLoginSyncEvent) { + return null; + } + if (latestBimLoginSyncEvent.expiresAt <= Date.now()) { + latestBimLoginSyncEvent = null; + return null; + } + return latestBimLoginSyncEvent; +}; + const sanitizeReturnTo = (value, fallback = "/") => { if (typeof value !== "string" || !value.startsWith("/") || value.startsWith("//")) { return fallback; @@ -1749,16 +1772,30 @@ const handleGetUploadVersions = async (req, res, searchParams) => { const handleAuthSession = async (req, res) => { const session = await getValidatedBimSession(req, res); + const loginSyncEvent = getActiveBimLoginSyncEvent(); + res.setHeader("Cache-Control", "no-store"); sendJSON(res, 200, { ok: true, authRequired: BIM_AUTH_REQUIRED, authenticated: !!session, canShare: !BIM_AUTH_REQUIRED || !!session, user: session?.user || null, + loginSyncEventId: loginSyncEvent?.id || null, + loginSyncEventCreatedAt: loginSyncEvent?.createdAt || null, loginUrl: session ? null : buildLauncherLoginUrl(req, "/") }); }; +const handleLoginSync = (res) => { + const event = markBimLoginSyncEvent(); + setCors(res); + res.statusCode = 200; + res.setHeader("Content-Type", "text/plain; charset=utf-8"); + res.setHeader("Cache-Control", "no-store"); + res.setHeader("X-NODEDC-Session-Event", event.id); + res.end("ok"); +}; + const handleLauncherHandoff = async (req, res, url) => { const token = url.searchParams.get("token") || ""; const nextPath = sanitizeReturnTo(url.searchParams.get("next_path") || url.searchParams.get("returnTo") || "/"); @@ -3435,6 +3472,10 @@ const requestHandler = async (req, res) => { return handleSharePromoted(res, url); } + if (req.method === "GET" && url.pathname === "/auth/login-sync") { + return handleLoginSync(res); + } + if (req.method === "GET" && url.pathname === "/auth/logout") { return handleLogout(req, res); }