/** * common.js — Shared utilities for all Deep Freeze iQ apps. * * Loaded by each app's index.html via: * * * Currently provides: * (1) Session-expired detection + blocking modal (bug 54324). * Auto-intercepts every fetch() — when a lambda returns HTTP 401 or a * JSON body containing errorType: "TOKEN_EXPIRED", a modal pops up, * offering the user a button to return to the Deep Freeze Cloud console. * The originating fetch promise is left pending so the app does not * continue running (no console errors, no stale UI updates) — the user * must click the button to navigate away. * * (2) IQCommon global namespace for future shared helpers (theme sync, * toasts, etc.). Add new functions under `window.IQCommon.*`. */ (function () { 'use strict'; if (window.IQCommon && window.IQCommon.__sessionExpiredInstalled) { // Idempotent — multiple script tags or hot reloads must not re-install. return; } // Capture this script's own URL while it executes synchronously, so the // compaction trigger (below) can resolve the absolute path to its sibling // config JSON regardless of which app page (depth) loaded common.js. var COMMON_JS_SRC = (document.currentScript && document.currentScript.src) || ''; // ── Break "myChildWindow" tab reuse ────────────────────────────────────── // The Deep Freeze Cloud console opens iQ via: // window.open(iqUrl, 'myChildWindow') // The named target makes the browser REUSE any existing tab called // 'myChildWindow'. If that tab is showing the session-expired modal, the // user clicks the iQ link again and just gets the same stuck tab back. // // By renaming our window as soon as common.js loads, we stop the browser // from matching us — the next click on the DFC iQ link opens a BRAND-NEW // tab with a fresh session. The DFC console still has its `childWindow` // reference, so its postMessage(token) still works as before (postMessage // uses the Window object reference, not the name). try { if (window.name === 'myChildWindow' || window.name === '') { window.name = 'iqApp_' + Math.random().toString(36).slice(2, 10) + '_' + Date.now().toString(36); } } catch (e) {} // ── URL helper ─────────────────────────────────────────────────────────── // Derive the Deep Freeze Cloud console URL from the current host: // https://www30-iq.deepfreeze.com/... → https://www30.deepfreeze.com/ // https://www6-iq.deepfreeze.com/foo → https://www6.deepfreeze.com/ // Falls back to https://www.deepfreeze.com/ on hosts without the -iq suffix // (localhost and any non-prod environment). function getDFCConsoleUrl() { var host = window.location.hostname; var dfcHost = host.replace(/-iq\./, '.'); if (dfcHost === host) { dfcHost = 'www.deepfreeze.com'; } return window.location.protocol + '//' + dfcHost + '/Home/Dashboard'; } // ── CSS for the session-expired modal ──────────────────────────────────── // // IMPORTANT: All colours are HARDCODED — not via CSS variables — so the // modal looks identical across every iQ app and the main portal, // regardless of how the host page has set --card, --fg-1, --fg-2, etc. // !important is used on the layout-critical and colour properties to // defeat any host stylesheet that might target generic descendants. var STYLE_ID = 'iqcommon-session-expired-styles'; function injectStyles() { if (document.getElementById(STYLE_ID)) return; var style = document.createElement('style'); style.id = STYLE_ID; style.textContent = [ '.iqcommon-session-overlay {', ' position: fixed !important; inset: 0 !important;', ' background: rgba(15, 23, 42, 0.65) !important;', ' z-index: 2147483647 !important;', /* highest 32-bit int — always on top */ ' display: flex !important;', ' align-items: center !important; justify-content: center !important;', ' backdrop-filter: blur(4px);', ' -webkit-backdrop-filter: blur(4px);', ' font-family: -apple-system, system-ui, "Segoe UI", Roboto, sans-serif !important;', '}', '.iqcommon-session-modal {', ' background: #ffffff !important;', ' color: #111827 !important;', ' max-width: 480px !important; width: 90% !important;', ' padding: 32px 28px !important;', ' border-radius: 14px !important;', ' border: none !important;', ' box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3) !important;', ' text-align: center !important;', ' animation: iqcommonFadeIn 0.18s ease-out;', '}', '@keyframes iqcommonFadeIn {', ' from { opacity: 0; transform: translateY(-8px); }', ' to { opacity: 1; transform: translateY(0); }', '}', '.iqcommon-session-icon {', ' font-size: 40px !important; line-height: 1 !important;', ' margin: 0 0 12px !important;', ' color: #f59e0b !important;', '}', '.iqcommon-session-title {', ' font-size: 22px !important; font-weight: 700 !important;', ' margin: 0 0 12px !important;', ' color: #111827 !important;', '}', '.iqcommon-session-message {', ' font-size: 14px !important; line-height: 1.55 !important;', ' margin: 0 0 24px !important;', ' color: #4b5563 !important;', '}', '.iqcommon-session-button {', ' background: #2563eb !important; color: #ffffff !important;', ' border: none !important; cursor: pointer !important;', ' padding: 12px 24px !important;', ' font-size: 14px !important; font-weight: 600 !important;', ' border-radius: 8px !important;', ' transition: background 0.15s ease;', ' font-family: inherit !important;', ' display: inline-block !important;', '}', '.iqcommon-session-button:hover { background: #1d4ed8 !important; }', '.iqcommon-session-button:focus { outline: 2px solid #93c5fd !important; outline-offset: 2px !important; }', '/* Canonical demo-badge info icon — consistent SVG + color across all apps */', '.demo-badge-icon { display: inline-flex !important; align-items: center; justify-content: center; line-height: 0; color: #b45309; }', '.demo-badge-icon svg { width: 15px; height: 15px; stroke: currentColor; fill: none; stroke-width: 1.9; }', '[data-theme="dark"] .demo-badge-icon, html.dark .demo-badge-icon, .dark .demo-badge-icon, body.dark-mode .demo-badge-icon { color: #fbbf24; }', '/* Consistent slim demo-banner height across all apps (desktop). Mobile keeps its own wrapping. */', '@media (min-width: 768px) {', ' :root { --demo-h: 28px !important; --banner-h: 28px !important; }', ' .demo-badge-container, .demo-banner, .brain-demo-banner {', ' height: 28px !important; min-height: 28px !important; max-height: 28px !important;', ' padding-top: 0 !important; padding-bottom: 0 !important;', ' box-sizing: border-box !important;', ' align-items: center !important; justify-content: center !important;', ' }', ' /* Only force flex when the banner is actually shown — never override the display:none hidden state. */', ' .demo-badge-container.show, .demo-banner.show, .brain-demo-banner.show { display: flex !important; }', ' /* Consistent Back-to-iQ button size across all apps */', ' .back-to-iq, .brain-back-to-iq { padding: 4px 11px 4px 8px !important; gap: 5px !important; min-height: 27px !important; box-sizing: border-box !important; }', ' .back-to-iq svg, .brain-back-to-iq svg { width: 11px !important; height: 11px !important; }', '}' ].join('\n'); // Inject as early as possible so the modal renders correctly even if // it shows before the app's own styles finish loading. (document.head || document.documentElement).appendChild(style); } // ── Modal show/hide ────────────────────────────────────────────────────── var OVERLAY_ID = 'iqcommon-session-expired-overlay'; var modalShown = false; // ── First-load grace period ────────────────────────────────────────────── // The Deep Freeze Cloud console opens iQ via window.open and sends the // JWT 1 second later via postMessage. During that ~1 second window the // iQ tab has no token yet, so any early API call returns 401 and we'd // otherwise flash the session-expired modal. // // To avoid that flash, if a modal-show request comes in during the first // few seconds AND no fresh token has yet been received via postMessage, // we DEFER showing the modal. When the postMessage arrives, the deferred // show is cancelled and the user never sees the flash. If no postMessage // ever arrives, the modal shows after the grace period expires. var PAGE_LOAD_TIME = Date.now(); var POSTMESSAGE_GRACE_MS = 5000; // During a recovery flow the DFC console's /BTS endpoint often returns a // stale/cached JWT on the first call after recovery, then a fresh one a // few seconds later. A 5s grace isn't long enough to bridge that gap, so // the modal flashes on top of an unloadable apps grid. Extend the grace // significantly when we know we're mid-recovery — the genuine "no fresh // token ever" case still surfaces the modal, just 15s later. var RECOVERY_GRACE_MS = 20000; var freshTokenReceived = false; var pendingShowTimer = null; var pendingShowOpts = null; // ── Recovery-flow detection ────────────────────────────────────────────── // When the user clicks the modal's "Go to Deep Freeze Cloud Console" // button, we stash a timestamp in localStorage. When the next iQ page // loads and finds this flag is recent, we know the user is already // mid-recovery — if the modal needs to show AGAIN, the original "reopen // iQ Apps page" instruction is no longer useful (they just did that). // Instead we surface a clearer message asking them to sign in again. // // localStorage is shared across iQ-side tabs of the same origin, so the // flag set in the old tab is visible in the new tab opened by the DFC // iQ link. var RECOVERY_FLAG_KEY = 'iqcommon-recovery-ts'; var RECOVERY_FLAG_TTL_MS = 5 * 60 * 1000; // 5 minutes var inRecoveryFlow = false; try { var recoveryTs = parseInt(localStorage.getItem(RECOVERY_FLAG_KEY) || '0', 10); if (recoveryTs && (Date.now() - recoveryTs) < RECOVERY_FLAG_TTL_MS) { inRecoveryFlow = true; } else if (recoveryTs) { // Stale flag — clear it. localStorage.removeItem(RECOVERY_FLAG_KEY); } } catch (e) {} var RECOVERY_MESSAGE = 'Your Deep Freeze Cloud login session is no longer active. Please sign out, sign back in, and reopen the iQ Apps page.'; // Default message — used inside child apps. The main iQ portal page sets // its own variant via setSessionExpiredMessage() because the user does // NOT need to "open this app again" — they're already on the apps list. var DEFAULT_MESSAGE = 'For security, your login session has timed out. Please go back to the Deep Freeze Cloud console, reopen the iQ Apps page, and open this app again.'; var customMessage = null; // overrides DEFAULT_MESSAGE when set function setSessionExpiredMessage(text) { if (typeof text === 'string' && text) { customMessage = text; } } function showSessionExpiredModal(opts) { if (modalShown) return; // Grace-period check: during the first few seconds of page load, the // postMessage from DFC console may not have arrived yet. If a 401 // comes in during that window AND we haven't received a fresh token // yet, defer the modal — the postMessage will likely arrive and // cancel it. // // During a recovery flow we extend the grace because /BTS often // returns a stale token first and the fresh one a few seconds later; // 5s isn't long enough to bridge that gap. var graceMs = inRecoveryFlow ? RECOVERY_GRACE_MS : POSTMESSAGE_GRACE_MS; var elapsed = Date.now() - PAGE_LOAD_TIME; if (elapsed < graceMs && !freshTokenReceived) { if (pendingShowTimer) return; // already deferred — no-op pendingShowOpts = opts || null; pendingShowTimer = setTimeout(function () { pendingShowTimer = null; if (!freshTokenReceived) { // Grace expired without a fresh token — show modal now. showSessionExpiredModal(pendingShowOpts); } pendingShowOpts = null; }, graceMs - elapsed); return; } modalShown = true; injectStyles(); // Precedence: // 1. explicit opts.message (caller override) // 2. inRecoveryFlow → RECOVERY_MESSAGE (user already tried recovery) // 3. customMessage (per-page override via setSessionExpiredMessage) // 4. DEFAULT_MESSAGE var explicitMsg = opts && typeof opts.message === 'string' && opts.message; var msg = explicitMsg || (inRecoveryFlow ? RECOVERY_MESSAGE : null) || customMessage || DEFAULT_MESSAGE; var overlay = document.getElementById(OVERLAY_ID); if (!overlay) { overlay = document.createElement('div'); overlay.id = OVERLAY_ID; overlay.className = 'iqcommon-session-overlay'; overlay.setAttribute('role', 'dialog'); overlay.setAttribute('aria-modal', 'true'); overlay.setAttribute('aria-labelledby', 'iqcommon-session-title'); // Build the modal DOM with textContent for the message (XSS-safe) // so callers can safely pass message strings. var modalDiv = document.createElement('div'); modalDiv.className = 'iqcommon-session-modal'; var iconDiv = document.createElement('div'); iconDiv.className = 'iqcommon-session-icon'; iconDiv.setAttribute('aria-hidden', 'true'); iconDiv.innerHTML = '⚠'; var titleEl = document.createElement('h2'); titleEl.id = 'iqcommon-session-title'; titleEl.className = 'iqcommon-session-title'; titleEl.textContent = 'Your session has expired'; var msgEl = document.createElement('p'); msgEl.className = 'iqcommon-session-message'; msgEl.textContent = msg; var btn = document.createElement('button'); btn.id = 'iqcommon-session-redirect'; btn.type = 'button'; btn.className = 'iqcommon-session-button'; btn.textContent = 'Go to Deep Freeze Cloud Console'; modalDiv.appendChild(iconDiv); modalDiv.appendChild(titleEl); modalDiv.appendChild(msgEl); modalDiv.appendChild(btn); overlay.appendChild(modalDiv); (document.body || document.documentElement).appendChild(overlay); btn.addEventListener('click', function () { // Mark that the user is mid-recovery so the NEXT iQ page // load can detect a repeat-attempt and show a clearer // message if the new JWT is also expired. try { localStorage.setItem(RECOVERY_FLAG_KEY, String(Date.now())); } catch (e) {} // Clear the expired JWT from per-tab storage so that: // - If the user uses the browser back button to return to // this iQ origin, app-pro.js won't pick up the bad token // from sessionStorage and immediately fire a doomed fetch. // - Any localStorage hand-off token (mobile flow) sitting // around from before the expiry is also dropped. // The new iQ tab that DFC opens has its own (empty) session // storage, so this only affects same-tab navigation. try { sessionStorage.removeItem('jwtToken'); } catch (e) {} try { localStorage.removeItem('pendingJwtToken'); localStorage.removeItem('pendingJwtTokenTime'); } catch (e) {} window.location.href = getDFCConsoleUrl(); }); // Auto-focus the button so the user can press Enter to redirect. setTimeout(function () { try { btn.focus(); } catch (e) {} }, 0); } else { // Modal already in DOM — just make sure it's visible (and update // the message text in case the caller passed a different one). var existingMsg = overlay.querySelector('.iqcommon-session-message'); if (existingMsg) existingMsg.textContent = msg; overlay.style.display = 'flex'; } } // ── JWT helpers ────────────────────────────────────────────────────────── // Decode a JWT payload without signature verification (matches the lambdas). // Returns null on any parse failure. function decodeJwtPayload(jwt) { if (!jwt || typeof jwt !== 'string') return null; var parts = jwt.split('.'); if (parts.length !== 3) return null; try { var b64 = parts[1].replace(/-/g, '+').replace(/_/g, '/'); // Pad base64 to a multiple of 4 while (b64.length % 4) b64 += '='; return JSON.parse(atob(b64)); } catch (e) { return null; } } // Returns true if the JWT's exp claim is in the past (allows a small // skew threshold to avoid sending tokens that will fail server-side anyway). function isJwtExpired(jwt) { var payload = decodeJwtPayload(jwt); if (!payload || typeof payload.exp !== 'number') return false; return (payload.exp - Math.floor(Date.now() / 1000)) < 20; } // Look for a JWT in fetch arguments — checks the JSON-stringified body // for any of the known JWT field names. Child apps use `token`; the main // iQ portal's app-pro.js uses `jwtToken`. Returns the JWT string if found, // null otherwise. function extractJwtFromFetchArgs(args) { if (!args || args.length < 2) return null; var init = args[1]; if (!init || !init.body) return null; try { var body = typeof init.body === 'string' ? init.body : null; if (!body) return null; // Cheap pre-filter — bail early if body doesn't smell like JWT-bearing. if (body.indexOf('token') === -1) return null; var obj = JSON.parse(body); if (!obj) return null; if (typeof obj.token === 'string' && obj.token) return obj.token; if (typeof obj.jwtToken === 'string' && obj.jwtToken) return obj.jwtToken; return null; } catch (e) { return null; } } // ── User locale (timezone + date format) from JWT claims ───────────────── // BrainTokenServer adds principal tags: tz = IANA zone (e.g. "Asia/Kolkata"), // df = .NET date pattern (e.g. "dd/MM/yyyy"). Parquet timestamps are UTC, so // IQCommon.formatLocal() converts a value to the user's zone + pattern for // display. Defaults to the browser timezone and ISO date until a token is seen. var userTz = (function () { try { return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; } catch (e) { return 'UTC'; } })(); var userDateFormat = 'yyyy-MM-dd'; function captureUserLocaleFromJwt(jwt) { var payload = decodeJwtPayload(jwt); if (!payload) return; var tags = payload['https://aws.amazon.com/tags']; var pt = tags && tags.principal_tags; if (!pt) return; var tz = Array.isArray(pt.tz) ? pt.tz[0] : pt.tz; var df = Array.isArray(pt.df) ? pt.df[0] : pt.df; if (tz) userTz = tz; if (df) userDateFormat = df; } // Parse an incoming value to a Date. Epoch numbers and ISO strings with an // explicit offset/Z are taken as-is. Naive date/datetime strings (no Z/offset) // are treated as UTC — that's how the lambdas emit them. function toDate(v) { if (v === null || v === undefined || v === '') return null; if (v instanceof Date) return v; if (typeof v === 'number') return new Date(v); var s = String(v).trim(); if (/^\d{10,}$/.test(s)) return new Date(parseInt(s, 10)); // epoch ms var hasTz = /[zZ]$/.test(s) || /[+\-]\d{2}:?\d{2}$/.test(s); if (!hasTz && /^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}(:\d{2})?(\.\d+)?)?$/.test(s)) { s = s.replace(' ', 'T'); if (s.indexOf('T') === -1) s += 'T00:00:00'; s += 'Z'; } var d = new Date(s); return isNaN(d.getTime()) ? null : d; } // Tz-correct date parts via Intl (works for any IANA zone, DST-aware per date). function tzParts(d, tz) { var f = new Intl.DateTimeFormat('en-US', { timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }); var m = {}; f.formatToParts(d).forEach(function (p) { m[p.type] = p.value; }); var hour = (m.hour === '24') ? '00' : m.hour; return { yyyy: m.year, yy: String(m.year).slice(-2), MMMM: new Intl.DateTimeFormat('en-US', { timeZone: tz, month: 'long' }).format(d), MMM: new Intl.DateTimeFormat('en-US', { timeZone: tz, month: 'short' }).format(d), MM: m.month, M: String(parseInt(m.month, 10)), dd: m.day, d: String(parseInt(m.day, 10)), HH: hour, mm: m.minute, ss: m.second }; } // Substitute .NET date tokens (longest first) with tz-correct parts. function applyPattern(pattern, p) { return pattern.replace(/yyyy|yy|MMMM|MMM|MM|M|dd|d|HH|mm|ss/g, function (t) { return (t in p) ? p[t] : t; }); } // Public: format a (UTC) date value in the user's timezone + date format. // opts.withTime -> append " HH:mm" (24h); opts.seconds -> include seconds. // Never throws: returns the original value on any failure, so a bad tz/value // shows the old text rather than breaking the UI. function formatLocal(value, opts) { opts = opts || {}; try { var d = toDate(value); if (!d) return value; var p = tzParts(d, userTz); var out = applyPattern(userDateFormat || 'yyyy-MM-dd', p); if (opts.withTime) { out += ' ' + p.HH + ':' + p.mm + (opts.seconds ? ':' + p.ss : ''); } return out; } catch (e) { return value; } } // Public: format a (UTC) value as time-of-day only in the user's timezone (24h). // opts.seconds -> include seconds. Returns original value on any failure. function formatTime(value, opts) { opts = opts || {}; try { var d = toDate(value); if (!d) return value; var p = tzParts(d, userTz); return p.HH + ':' + p.mm + (opts.seconds ? ':' + p.ss : ''); } catch (e) { return value; } } // ── Fetch interception ─────────────────────────────────────────────────── // Wraps window.fetch to detect token expiry in three ways: // (1) Proactive — if the outgoing request carries an already-expired JWT, // show the modal and skip the network call entirely. No browser- // level "Failed to load resource" log appears. // (2) HTTP 401 status from the lambda. // (3) Successful response whose body contains errorType: "TOKEN_EXPIRED". // // When detected: show the modal, then return a never-resolving promise so // the caller's await hangs and downstream code never runs (no console.error, // no demo-data fallback, no extra UI churn). The user must click the modal // button to navigate away. var originalFetch = window.fetch.bind(window); function isTokenExpiredBody(text) { // Quick string check before JSON parse — body may not be JSON. if (!text || text.indexOf('TOKEN_EXPIRED') === -1) return false; try { var obj = JSON.parse(text); if (obj && (obj.errorType === 'TOKEN_EXPIRED' || obj.error === 'TOKEN_EXPIRED')) { return true; } // Some lambdas return { body: '{"errorType":"TOKEN_EXPIRED"}' } wrapped. if (obj && typeof obj.body === 'string' && obj.body.indexOf('TOKEN_EXPIRED') !== -1) { try { var inner = JSON.parse(obj.body); if (inner && (inner.errorType === 'TOKEN_EXPIRED' || inner.error === 'TOKEN_EXPIRED')) { return true; } } catch (e) { /* fall through */ } } } catch (e) { // Not JSON; the substring match was incidental — ignore. } return false; } window.fetch = function patchedFetch() { // (1) Proactive check — if outgoing request carries an expired JWT, // skip the network call to avoid a browser-level 401 console log. var jwt = extractJwtFromFetchArgs(arguments); if (jwt) captureUserLocaleFromJwt(jwt); if (jwt && isJwtExpired(jwt)) { showSessionExpiredModal(); return new Promise(function () { /* never resolves */ }); } var fetchPromise = originalFetch.apply(this, arguments); return fetchPromise.then(function (response) { // Fast path: clear 401 from the lambda. if (response && response.status === 401) { showSessionExpiredModal(); return new Promise(function () { /* never resolves — app code halts cleanly */ }); } // Slow path: lambdas occasionally return 200 with a TOKEN_EXPIRED body. // Clone before any caller reads it; cloning is cheap and safe. try { var cloned = response.clone(); return cloned.text().then(function (text) { if (isTokenExpiredBody(text)) { showSessionExpiredModal(); return new Promise(function () { /* never resolves */ }); } return response; }).catch(function () { // Cloning or reading failed — return the original response // unchanged. The app's existing handlers can deal with it. return response; }); } catch (e) { return response; } }); }; // ── Cross-tab JWT recovery ─────────────────────────────────────────────── // When the user clicks the "iQ" link in the Deep Freeze Cloud console, the // console JavaScript does: // 1. window.open(iqUrl, 'myChildWindow') — opens or REUSES the named tab // 2. AJAX /BTS to get a fresh JWT // 3. childWindow.postMessage({type: 'token', token: jwtToken}, '*') // // If a previous iQ tab is open showing the session-expired modal, the named- // tab reuse means the modal-state iQ page comes back into focus instead of // a fresh load. The postMessage IS sent but the modal-state page would // otherwise ignore it and the user is stuck. // // This listener catches that postMessage, stores the fresh JWT, and reloads // the page (only if the modal is currently shown) so the app re-initialises // with the new JWT and the modal goes away. window.addEventListener('message', function (event) { if (!event || !event.data || event.data.type !== 'token') return; var newToken = event.data.token; if (!newToken || typeof newToken !== 'string') return; if (isJwtExpired(newToken)) return; // ignore stale tokens // Mark that a fresh token has arrived. This cancels any pending // grace-period modal-show timer and prevents future deferrals from // ever firing. freshTokenReceived = true; if (pendingShowTimer) { clearTimeout(pendingShowTimer); pendingShowTimer = null; pendingShowOpts = null; } // Recovery succeeded — clear the flag so future expiries get the // normal (not recovery) message. try { localStorage.removeItem(RECOVERY_FLAG_KEY); } catch (e) {} inRecoveryFlow = false; // A fresh token just arrived — if this is a child app, kick off the // background compaction now (guarded to run once per page load). maybeTriggerAppCompaction(newToken); // If the session-expired modal IS already showing (stuck-tab case), // persist the fresh JWT and reload so the app re-initialises and // the modal goes away. In normal flow (no modal), app-pro.js's own // postMessage listener handles everything — we don't need to act. var overlay = document.getElementById(OVERLAY_ID); if (overlay) { try { sessionStorage.setItem('jwtToken', newToken); } catch (e) {} window.location.reload(); } }, false); // ── Brain compaction trigger (child apps only) ─────────────────────────── // On the main iQ portal, app-pro.js → compaction-runner.js already kicks off // the Brain compaction pipeline on page load. Child apps don't load that // runner, so we trigger the SAME pipeline here from common.js — once per app // page load, in the background, fire-and-forget. compaction-runner.js is left // untouched so the main page keeps working exactly as before. // // Scope: only real child-app pages (path under /apps/). The main portal page // is skipped (it has its own trigger). A few apps opt out entirely. var COMPACTION_IGNORE = [ 'heartbeat', 'multi_site_inventory', 'multi_sites_computer_search_v2', 'multi_sites_info' ]; var compactionTriggered = false; // once per page load function isChildAppContext() { try { var path = location.pathname || ''; if (path.indexOf('/apps/') === -1) return false; // main portal → skip for (var i = 0; i < COMPACTION_IGNORE.length; i++) { if (path.indexOf('/' + COMPACTION_IGNORE[i] + '/') !== -1) return false; } return true; } catch (e) { return false; } } // Site key ("ck") lives in the JWT's AWS principal tags. Mirrors how the // lambdas read it (principal_tags.ck), and how captureUserLocaleFromJwt // reads sibling tags — values may be normalized to single-element arrays. function siteKeyFromJwt(jwt) { var payload = decodeJwtPayload(jwt); if (!payload) return null; var tags = payload['https://aws.amazon.com/tags']; var pt = tags && tags.principal_tags; if (!pt) return null; var ck = Array.isArray(pt.ck) ? pt.ck[0] : pt.ck; return ck || null; } // Resolve the absolute URL of compaction-config.json relative to common.js // itself: common.js is at /js/common.js, config at // /js/compaction/compaction-config.json. function compactionConfigUrl() { try { return new URL('compaction/compaction-config.json', COMMON_JS_SRC).href; } catch (e) { return null; } } var COMPACTION_LOG = '[BrainCompaction][app]'; function maybeTriggerAppCompaction(jwt) { if (compactionTriggered) return; if (!isChildAppContext()) return; if (!jwt || typeof jwt !== 'string' || isJwtExpired(jwt)) return; var siteKey = siteKeyFromJwt(jwt); if (!siteKey) return; compactionTriggered = true; console.log(COMPACTION_LOG, 'triggering for site', siteKey); var run = function () { var cfgUrl = compactionConfigUrl(); if (!cfgUrl) return; // Same-origin GET of the tiny static config (browser-cached) — one // source of truth for the pipeline URL, shared with the main page. fetch(cfgUrl) .then(function (res) { return res.ok ? res.json() : null; }) .then(function (cfg) { if (!cfg || !cfg.enabled) { console.log(COMPACTION_LOG, 'disabled or no config; skipping'); return; } var url = cfg.function_urls && cfg.function_urls.pipeline; if (!url) return; // Normal CORS fetch (the pipeline Lambda URL allows all // origins) so we can read the JSON response — same as the // main page. On network/CORS failure, fall back to a // no-cors keepalive so the request still reaches the Lambda. fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ customer_id: siteKey }) }) .then(function (res) { return res.ok ? res.json() : null; }) .then(function (data) { console.log(COMPACTION_LOG, 'pipeline response', data); }) .catch(function () { console.log(COMPACTION_LOG, 'direct call failed; sending fire-and-forget'); try { fetch(url, { method: 'POST', mode: 'no-cors', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ customer_id: siteKey }), keepalive: true }).catch(function () {}); } catch (e) {} }); }) .catch(function () {}); }; if (typeof window.requestIdleCallback === 'function') { window.requestIdleCallback(run, { timeout: 5000 }); } else { setTimeout(run, 0); } } // ── User email display (works even in demo/sample mode) ────────────────── // The signed-in user's email lives in the JWT principal tags (same place as // tz/df). Apps render it in a `.user-email` / #userEmail / #userEmailDisplay // element, but only after a successful data call. This populates it straight // from the token so it shows even when an app falls back to demo data — so // each app no longer needs its own email-decoding logic. function getUserEmailFromJwt(jwt) { var payload = decodeJwtPayload(jwt); if (!payload) return ''; var tags = payload['https://aws.amazon.com/tags']; var pt = tags && tags.principal_tags; var em = pt && pt.email; if (Array.isArray(em)) em = em[0]; if (!em && typeof payload.email === 'string') em = payload.email; return em || ''; } function populateUserEmail() { try { var jwt = (window.sessionStorage && sessionStorage.getItem('jwtToken')) || ''; if (!jwt) { try { jwt = new URLSearchParams(window.location.search).get('token') || ''; } catch (e) {} } if (!jwt) return; var email = getUserEmailFromJwt(jwt); if (!email) return; var spans = [].slice.call(document.querySelectorAll('.user-email, .brain-user-email, #userEmail, #userEmailDisplay')); // If the app already shows the email itself, drop any span we added and stop. var real = spans.filter(function (s) { return s.textContent && s.textContent.trim() && s.getAttribute('data-iq-email') !== '1'; }); if (real.length) { spans.forEach(function (s) { if (s.getAttribute('data-iq-email') === '1' && s.parentNode) s.parentNode.removeChild(s); }); return; } var empty = spans.filter(function (s) { return !(s.textContent && s.textContent.trim()); }); if (empty.length) { empty[0].textContent = email; empty[0].setAttribute('data-iq-email', '1'); return; } // No email element rendered (e.g. demo mode) — create one in the header. if (spans.some(function (s) { return s.getAttribute('data-iq-email') === '1'; })) return; var hr = document.querySelector('.header-right, .header-right-section, [class*="header-right"]'); if (!hr) return; var s = document.createElement('span'); // both classes so it picks up whichever the app styles (.user-email // on the inline-template apps, .brain-user-email on the bundled ones). s.className = 'user-email brain-user-email'; s.setAttribute('data-iq-email', '1'); s.textContent = email; hr.insertBefore(s, hr.firstChild); } catch (e) {} } // Headers render after this script (React/Babel), so poll briefly and watch // the DOM for the header appearing, then stop. // Demo/sample banners across apps use a `.demo-badge-icon` element; some // shipped a text/emoji glyph instead of the canonical info SVG. Swap any // glyph for the SVG so the demo bar looks identical everywhere. var DEMO_ICON_SVG = ''; function normalizeDemoBadgeIcon() { try { var icons = document.querySelectorAll('.demo-badge-icon'); for (var i = 0; i < icons.length; i++) { if (icons[i].querySelector('svg')) continue; // already an SVG icons[i].innerHTML = DEMO_ICON_SVG; } } catch (e) {} } function startUserEmailWatch() { function tick() { populateUserEmail(); normalizeDemoBadgeIcon(); } tick(); var n = 0; var iv = setInterval(function () { tick(); if (++n >= 6) clearInterval(iv); }, 700); try { var mo = new MutationObserver(function () { tick(); }); mo.observe(document.documentElement, { childList: true, subtree: true }); setTimeout(function () { try { mo.disconnect(); } catch (e) {} }, 10000); } catch (e) {} } // ── Public surface ─────────────────────────────────────────────────────── window.IQCommon = window.IQCommon || {}; window.IQCommon.showSessionExpiredModal = showSessionExpiredModal; window.IQCommon.setSessionExpiredMessage = setSessionExpiredMessage; window.IQCommon.getDFCConsoleUrl = getDFCConsoleUrl; // Date/timezone localization (tz + df from the JWT claims). window.IQCommon.formatLocal = formatLocal; window.IQCommon.formatDate = function (v) { return formatLocal(v, { withTime: false }); }; window.IQCommon.formatDateTime = function (v, withSeconds) { return formatLocal(v, { withTime: true, seconds: !!withSeconds }); }; window.IQCommon.formatTime = function (v, withSeconds) { return formatTime(v, { seconds: !!withSeconds }); }; window.IQCommon.setTokenLocale = captureUserLocaleFromJwt; window.IQCommon.getUserTz = function () { return userTz; }; window.IQCommon.getUserDateFormat = function () { return userDateFormat; }; window.IQCommon.populateUserEmail = populateUserEmail; window.IQCommon.__sessionExpiredInstalled = true; // Capture locale from a token already stored in sessionStorage (delivered via // postMessage before this script ran). try { var _storedJwt = (window.sessionStorage && sessionStorage.getItem('jwtToken')) || null; if (_storedJwt) { captureUserLocaleFromJwt(_storedJwt); // Token already present at load (child app) — trigger compaction now. // If it's not there yet, the postMessage listener above will trigger // when the fresh token arrives. maybeTriggerAppCompaction(_storedJwt); } } catch (e) {} // Inject styles immediately so they're ready before any modal call, and // start populating the signed-in user's email (shows even in demo mode). if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', function () { injectStyles(); startUserEmailWatch(); }); } else { injectStyles(); startUserEmailWatch(); } })();