/**
* 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();
}
})();