/* * Global connection search — a floating, dim overlay available on every page. * * Drop-in: a page only needs . It injects its * own styles, a search box into the brand row, and the results overlay into the * body, loads the address-book search index, and wires the "/" shortcut. * * On the connections page, opening a folder from a result selects it in the * sidebar in place (via window.rgSelectFolder) instead of navigating; the * loaded index is also exposed as window.rgSearchIndex for that page's * credentials banner. Depends on rgFilters (filters.js), loaded on demand. * * Only an authenticated operator or above gets the box: the server writes the * caller's role on (0 anonymous ... 4 admin), and an * API-key user, who is served the anonymous variant, is confirmed through * /api/me first. A poweruser or above also gets "Connect to a host" beside * it, for an ad-hoc connection: it opens the editor in place on the * connections page (window.rgOpenAdhoc) and /connections.html#adhoc * elsewhere. It needs no Vault, so it shows even when the box has nothing to * search, and it goes with the box when the identity is dropped. The cached index is keyed to that identity and dropped the * moment the server stops recognising it (logout, expiry, a different user in * the same tab), so nothing of one user's address book is ever shown to the * next, nor on the login page. */ (function () { 'use strict'; if (window.__rgGlobalSearch) return; window.__rgGlobalSearch = true; var searchIndex = null, searchEngaged = false, searchFilters = null; var confirmedLevel = null; // role level /api/me answered with function esc(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) { return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]; }); } function attr(s) { return esc(s); } function highlight(text, tokens) { var safe = esc(text); if (!tokens || !tokens.length) return safe; var escaped = tokens.filter(function (t) { return t.length > 0; }) .map(function (t) { return t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }); if (!escaped.length) return safe; return safe.replace(new RegExp('(' + escaped.join('|') + ')', 'gi'), '$1'); } // "host (Tenant)" -> host over a quieter tenant sub-line function entryNameHtml(label, tokens) { var e = tokens ? function (t) { return highlight(t, tokens); } : esc; var m = /^(.*\S)\s+\(([^()]+)\)$/.exec(label || ''); if (!m) return e(label || ''); return '' + e(m[1]) + '' + e(m[2]) + ''; } function apiKey() { try { return sessionStorage.getItem('rustguac_api_key'); } catch (e) { return null; } } function headers(extra) { var h = extra || {}; var k = apiKey(); if (k) h['Authorization'] = 'Bearer ' + k; return h; } function injectUI() { var brand = document.querySelector('.brand-row'); if (brand) { brand.style.position = 'relative'; brand.style.zIndex = '300'; var gs = document.createElement('div'); gs.className = 'global-search'; gs.id = 'global-search'; gs.innerHTML = '' + '' + '/'; brand.appendChild(gs); } var ov = document.createElement('div'); ov.id = 'search-overlay'; ov.setAttribute('aria-hidden', 'true'); ov.innerHTML = ''; document.body.appendChild(ov); } // "Connect to a host": poweruser and above, the server's can_connect_adhoc. var ADHOC_LEVEL = 3; function injectAdhoc(level) { var brand = document.querySelector('.brand-row'); if (!brand || level == null || level < ADHOC_LEVEL || document.getElementById('rg-adhoc')) return; var b = document.createElement('button'); b.type = 'button'; b.id = 'rg-adhoc'; b.className = 'btn-add global-adhoc'; b.title = 'Connect to a host that is not a saved entry, with every option an entry has. Nothing is saved.'; b.innerHTML = '⚡ Connect to a host'; b.addEventListener('click', function () { if (typeof window.rgOpenAdhoc === 'function') window.rgOpenAdhoc(); else window.location.href = '/connections.html#adhoc'; }); brand.appendChild(b); } function removeAdhoc() { var b = document.getElementById('rg-adhoc'); if (b) b.parentNode.removeChild(b); } function input() { return document.getElementById('rg-search'); } function overlay() { return document.getElementById('search-overlay'); } // The index is cached per session so moving between pages is instant and // doesn't re-index. It is only re-fetched when the address book's version // changes (polled), which the connections page bumps when anything is // edited. The connections page itself always loads a fresh copy. var IDX_KEY = 'rustguac_search_index_v1', VER_KEY = 'rustguac_search_version_v1', WHO_KEY = 'rustguac_search_user_v1'; var knownVersion = null, pollTimer = null, versionChecked = false; function dropCache() { try { sessionStorage.removeItem(IDX_KEY); sessionStorage.removeItem(VER_KEY); sessionStorage.removeItem(WHO_KEY); } catch (e) {} searchIndex = null; knownVersion = null; window.rgSearchIndex = null; } // The server no longer recognises the caller (401/403): forget everything // and take the box away. The page itself sends them to the login page. function signedOut() { dropCache(); if (pollTimer) { clearInterval(pollTimer); pollTimer = null; } var gs = document.getElementById('global-search'); if (gs) gs.parentNode.removeChild(gs); var ov = overlay(); if (ov) ov.parentNode.removeChild(ov); removeAdhoc(); document.body.classList.remove('search-engaged'); searchEngaged = false; } function rejected(r) { return r.status === 401 || r.status === 403; } function buildIndex(rows) { return rows.map(function (r) { var e = r.entry; var label = e.display_name || e.name; var host = e.session_type === 'web' ? (e.url || '') : e.session_type === 'vdi' ? (e.container_image || '') : e.session_type === 'proxmox' ? ((e.proxmox_url || '') + (e.proxmox_vmid ? ' · VM ' + e.proxmox_vmid : '')) : (e.hostname || ''); var haystack = [e.name, e.display_name || '', host, e.username || '', e.proxmox_token_id || '', e.url || '', e.domain || '', e.session_type, r.folder_path].join(' ').toLowerCase(); return { scope: r.scope, folderPath: r.folder_path, folderName: r.folder_display_name || null, entry: e, label: label, host: host, haystack: haystack }; }); } function applyIndex(idx) { searchIndex = idx; window.rgSearchIndex = idx; try { document.dispatchEvent(new CustomEvent('rg-search-index-ready')); } catch (e) {} var inp = input(); if (!inp) return; inp.placeholder = 'Search ' + idx.length + ' connection' + (idx.length === 1 ? '' : 's') + '…'; inp.disabled = false; if (searchEngaged) runSearch(); } function fetchIndex(version) { var inp = input(); fetch('/api/addressbook/search-index', { headers: headers(), credentials: 'same-origin' }) .then(function (r) { if (rejected(r)) { signedOut(); return null; } if (!r.ok) throw new Error('index'); return r.json(); }) .then(function (data) { if (!data) return; var idx = buildIndex((data && data.entries) || []); applyIndex(idx); try { sessionStorage.setItem(IDX_KEY, JSON.stringify(idx)); if (version != null) sessionStorage.setItem(VER_KEY, String(version)); } catch (e) {} knownVersion = version; }) .catch(function () { if (!searchIndex && inp) inp.placeholder = 'Search unavailable'; }); } // Compare the server's address-book version with ours; fetch only on change. function checkVersion(cachedOk) { fetch('/api/addressbook/version', { headers: headers(), credentials: 'same-origin' }) .then(function (r) { if (rejected(r)) { signedOut(); return false; } if (r.status === 404) return 'none'; return r.ok ? r.json() : null; }) .then(function (data) { if (data === false) return; // signed out: nothing more to do if (data === 'none') { // No change counter on this server: nothing can tell a // cached index it is stale, so load it fresh on each page // (the cached copy is already painted) and do not poll. if (pollTimer) { clearInterval(pollTimer); pollTimer = null; } if (!cachedOk || !versionChecked) fetchIndex(null); versionChecked = true; return; } if (!data || typeof data.version !== 'number') { if (!cachedOk) fetchIndex(null); return; } // The counter starts again at 0 when the server restarts, so a // version alone could match an index cached before the // restart: key the cache to the server process (epoch) too. var v = (data.epoch != null ? data.epoch + ':' : '') + data.version; if (cachedOk && String(v) === String(knownVersion)) return; // up to date fetchIndex(v); }) .catch(function () { if (!cachedOk && !searchIndex) fetchIndex(null); }); } // `who` is the identity /api/me answered with; a cached index left by a // different login in this tab is thrown away rather than shown. function loadIndex(who) { var cached = null; try { if (sessionStorage.getItem(WHO_KEY) !== who) dropCache(); sessionStorage.setItem(WHO_KEY, who); var raw = sessionStorage.getItem(IDX_KEY); if (raw) { cached = JSON.parse(raw); knownVersion = sessionStorage.getItem(VER_KEY); } } catch (e) { cached = null; } if (cached && cached.length != null) applyIndex(cached); // instant, no re-index flash checkVersion(!!cached); // refresh only if changed if (!pollTimer) pollTimer = setInterval(function () { if (!document.hidden) checkVersion(true); }, 30000); } // The connections page's live refresh knows the moment the address book // changes; it calls this so the index is checked now rather than at the // next 30 s poll. window.rgSearchRecheck = function () { if (searchIndex) checkVersion(true); }; function setEngaged(on) { if (searchEngaged === on) return; searchEngaged = on; var ov = overlay(); ov.classList.toggle('open', on); ov.setAttribute('aria-hidden', on ? 'false' : 'true'); if (on) runSearch(); } function close() { var inp = input(); if (inp) { inp.value = ''; inp.blur(); } document.getElementById('global-search').classList.remove('has-value'); setEngaged(false); } function runSearch() { var inp = input(); if (!inp) return; var q = (inp.value || '').trim().toLowerCase(); document.getElementById('global-search').classList.toggle('has-value', !!inp.value); if (!searchEngaged) return; var res = document.getElementById('search-results'); if (!searchIndex) { res.innerHTML = '

Indexing connections…

'; return; } if (!q) { var all = searchIndex.map(function (row) { return { row: row, score: 0 }; }); all.sort(function (a, b) { if (a.row.folderPath !== b.row.folderPath) return a.row.folderPath.localeCompare(b.row.folderPath); return a.row.label.localeCompare(b.row.label); }); renderResults(all, '', []); return; } var tokens = q.split(/\s+/).filter(Boolean); var matches = []; for (var i = 0; i < searchIndex.length; i++) { var row = searchIndex[i], ok = true; for (var t = 0; t < tokens.length; t++) { if (row.haystack.indexOf(tokens[t]) === -1) { ok = false; break; } } if (!ok) continue; var first = tokens[0], lcL = row.label.toLowerCase(), lcN = row.entry.name.toLowerCase(), score = 0; if (lcL.indexOf(first) === 0 || lcN.indexOf(first) === 0) score += 100; else if (lcL.indexOf(first) !== -1 || lcN.indexOf(first) !== -1) score += 50; if ((row.host || '').toLowerCase().indexOf(first) !== -1) score += 20; if (row.folderPath.toLowerCase().indexOf(first) !== -1) score += 10; matches.push({ row: row, score: score }); } matches.sort(function (a, b) { if (b.score !== a.score) return b.score - a.score; if (a.row.folderPath !== b.row.folderPath) return a.row.folderPath.localeCompare(b.row.folderPath); return a.row.label.localeCompare(b.row.label); }); renderResults(matches, q, tokens); } function renderResults(matches, query, tokens) { var content = document.getElementById('search-results'); searchFilters.update(matches.map(function (m) { return m.row; })); var all = matches; matches = matches.filter(function (m) { return searchFilters.apply([m.row]).length > 0; }); var summary = document.getElementById('search-summary'); var total = searchIndex ? searchIndex.length : all.length; if (!query) { summary.innerHTML = searchFilters.active() ? '' + matches.length + ' of ' + total + ' connections' : 'All ' + total + ' connections'; } else { summary.innerHTML = '' + matches.length + '' + (searchFilters.active() && all.length !== matches.length ? ' of ' + all.length : '') + ' match' + (matches.length === 1 ? '' : 'es') + ' for ' + esc(query) + ''; } if (matches.length === 0) { content.innerHTML = '

' + (query ? (all.length ? 'No matches for ' + esc(query) + ' with the current filters' : 'No matches for ' + esc(query)) : 'No connections match the current filters') + '

'; return; } var max = 50, more = matches.length - max, shown = matches.slice(0, max); var html = '' + ''; shown.forEach(function (m) { var r = m.row, e = r.entry; var typeLabel = e.session_type.toUpperCase(); var typeCls = 'type-' + attr(e.session_type); var needsPrompt = (e.prompt_credentials || !e.has_credentials) && e.session_type !== 'web' && e.session_type !== 'proxmox'; var connectLabel = needsPrompt ? 'Login…' : 'Connect'; var userCol = e.session_type === 'proxmox' ? highlight(e.proxmox_token_id || '', tokens) : (e.username ? highlight(e.username, tokens) : ''); var scopeIcon = r.scope === 'shared' ? '⊕' : '▣'; var fName = r.folderName || r.folderPath; html += '' + '' + '' + '' + '' + '' + '' + ''; }); html += '
NameTypeHostUserFolder
' + entryNameHtml(r.label, tokens) + '' + esc(typeLabel) + '' + highlight(r.host || '', tokens) + '' + userCol + '' + scopeIcon + '' + highlight(fName, tokens) + '' + (fName !== r.folderPath ? '' + highlight(r.folderPath, tokens) + '' : '') + '' + '' + 'folder ↗
'; if (more > 0) html += '
+' + more + ' more — refine your search to narrow results
'; content.innerHTML = html; content.scrollTop = 0; } // "Open in a new window" (⧉): open a sized popup synchronously (inside the // click, so it is not blocked) on the splash page /launch.html, which // measures its own viewport once laid out and posts it back. The caller // creates the session at exactly that size, so nothing resizes after the // first paint. A popup's size cannot be read from here right after // window.open — it reports the opener's size until the window is laid out // (that was the RDP resize on ⧉). Resolves {width,height} either way: // defaults if the popup was blocked, or after 2.5s if it never answers. function rgOpenSessionWindow(name) { var win = window.open('/launch.html?name=' + encodeURIComponent(name), '_blank', 'width=1280,height=800,menubar=no,toolbar=no,location=no,status=no'); var size = new Promise(function (resolve) { var done = false; function fin(w, h) { if (done) return; done = true; window.removeEventListener('message', onMsg); clearTimeout(cap); resolve({ width: w > 0 ? w : 1280, height: h > 0 ? h : 800 }); } function onMsg(e) { if (e.origin !== location.origin || !e.data || e.data.rgLaunch !== 'size' || e.source !== win) return; fin(e.data.width, e.data.height); } var cap = null; if (!win) { fin(0, 0); return; } window.addEventListener('message', onMsg); cap = setTimeout(function () { var w = 0, h = 0; try { w = win.innerWidth; h = win.innerHeight; } catch (e) {} fin(w, h); }, 2500); }); return { win: win, size: size }; } window.rgOpenSessionWindow = rgOpenSessionWindow; // Connect to an entry: open the client in a window opened within this click // (keeps activation for fullscreen). Missing credentials send the user to // the connections page to set them. function doConnect(scope, folder, name, btn, newWindow) { // The ⧉ button gets a sized popup (measured, see above), a plain Connect a tab. var pop = newWindow ? rgOpenSessionWindow(name) : null; var win = pop ? pop.win : window.open('', '_blank'); if (btn) { btn.classList.add('loading'); btn.disabled = true; } var reset = function () { if (btn) { btn.classList.remove('loading'); btn.disabled = false; } }; var body = { width: window.innerWidth, height: window.innerHeight, dpi: Math.round((window.devicePixelRatio || 1) * 96) }; // screen_* only for the tab path (the server prefers it for a // fullscreen bitmap entry); a popup is sized by its measurement alone. if (!newWindow && window.screen && screen.width) { body.screen_width = screen.width; body.screen_height = screen.height; } (pop ? pop.size : Promise.resolve(null)).then(function (sz) { if (sz) { body.width = sz.width; body.height = sz.height; } return fetch('/api/addressbook/folders/' + encodeURIComponent(scope) + '/' + encodeURIComponent(folder) + '/entries/' + encodeURIComponent(name) + '/connect', { method: 'POST', headers: headers({ 'Content-Type': 'application/json' }), credentials: 'same-origin', body: JSON.stringify(body) }); }) .then(function (res) { if (!res.ok) { if (win && !win.closed) win.close(); if (res.status === 412) { openFolder(scope, folder); return null; } return res.text().then(function (t) { throw new Error(t); }); } return res.json(); }) .then(function (data) { if (!data) return; var _row = null, _si = searchIndex || []; for (var _k = 0; _k < _si.length; _k++) { var _r = _si[_k]; if (_r.scope === scope && _r.folderPath === folder && _r.entry.name === name) { _row = _r; break; } } var _fs = _row && _row.entry && _row.entry.fullscreen_on_connect; // A new window is explicitly windowed: fs=0 tells the client // not to fullscreen-on-connect even if the entry asks for it. var url = data.client_url + '?name=' + encodeURIComponent(name) + (newWindow ? '&fs=0' : (_fs ? '&fs=1' : '')); if (win && !win.closed) win.location.href = url; else window.open(url, '_blank'); }) .catch(function () { if (win && !win.closed) win.close(); }) .then(reset, reset); } // Zero-click fullscreen: a browser only lets the tab that holds the Connect // gesture go fullscreen, so for a fullscreen-on-connect entry we fullscreen // THIS tab now and load the session in a full-viewport iframe (the embedded // client fills it). No new tab, no second click. If the browser refuses // fullscreen the session still loads in the overlay, filling the tab. function rgLaunchFullscreen(scope, folder, name, btn) { var el = document.documentElement; var req = el.requestFullscreen || el.webkitRequestFullscreen || el.mozRequestFullScreen || el.msRequestFullscreen; // The session must be created at the viewport's FINAL size — the one // the frame will paint into — or the first picture is a fit of some // other size that the post-connect resize then snaps. That size cannot // be predicted: screen.width/height overshoot on a notched Mac (the // strip beside the camera is never part of a fullscreen viewport) and // the transition is asynchronous. So: enter fullscreen first, wait for // the viewport to stop changing, measure it, then connect. Refused (or // no Fullscreen API): the overlay fills the tab as it is, so measure // that straight away. Costs the transition time (~half a second) // before the connect request goes out; buys zero resizes. var fsState = null, frameReady = false; // null = pending, true = entered, false = refused var started = false, settleTimer = null, lastW = 0, lastH = 0; var w0 = window.innerWidth, h0 = window.innerHeight, fsAt = 0; // pre-fullscreen size function pushViewport() { if (!frameReady || fsState === null) return; var fr = document.getElementById('rg-session-frame'); try { if (fr && fr.contentWindow) fr.contentWindow.postMessage({ rgLauncher: 'viewport', fullscreen: fsState }, location.origin); } catch (e) {} } // Connect once the viewport has held the same size for 150ms. function armSettle() { lastW = window.innerWidth; lastH = window.innerHeight; clearTimeout(settleTimer); settleTimer = setTimeout(function () { if (window.innerWidth !== lastW || window.innerHeight !== lastH) { armSettle(); return; } // Entered fullscreen but the viewport still has its windowed // size: the resize has not landed yet (some browsers fire // fullscreenchange first). Keep waiting, up to 1.5s. if (fsState === true && window.innerWidth === w0 && window.innerHeight === h0 && Date.now() - fsAt < 1500) { armSettle(); return; } startConnect(); }, 150); } function onResize() { if (!started) armSettle(); } function fsRefused() { if (fsState === null) { fsState = false; pushViewport(); startConnect(); } } window.addEventListener('resize', onResize); if (req) { try { var fp = req.call(el); if (fp && fp.catch) fp.catch(fsRefused); } catch (e) { fsRefused(); } // No fullscreenchange within 3s (a browser that returns no promise // and never enters): treat as refused. setTimeout(fsRefused, 3000); } else fsRefused(); var ov = document.createElement('div'); ov.id = 'rg-session-overlay'; ov.innerHTML = '
' + '' + '' + ''; document.body.appendChild(ov); if (btn) { btn.classList.add('loading'); btn.disabled = true; } var done = false, enteredFs = false; function teardown() { if (done) return; done = true; window.removeEventListener('message', onMsg); window.removeEventListener('resize', onResize); clearTimeout(settleTimer); document.removeEventListener('fullscreenchange', onFsChange); var o = document.getElementById('rg-session-overlay'); if (o) o.remove(); if (btn) { btn.classList.remove('loading'); btn.disabled = false; } try { if (document.fullscreenElement && document.exitFullscreen) document.exitFullscreen(); } catch (e) {} } function onMsg(e) { if (e.origin !== location.origin || !e.data) return; if (e.data.rgSession === 'ready') { var sp = document.getElementById('rg-session-spinner'); if (sp) sp.style.display = 'none'; frameReady = true; pushViewport(); } else if (e.data.rgSession === 'end') teardown(); } function onFsChange() { if (document.fullscreenElement) { enteredFs = true; if (fsState === null) { fsState = true; fsAt = Date.now(); pushViewport(); } // Entered: the viewport may already be final or still animating; // the settle check covers both. if (!started) armSettle(); } // Leaving fullscreen (Escape held, F11, the OS) keeps the session: // it carries on at the tab's size and the button takes it back // to fullscreen. Only Exit, or the session ending, closes it. else if (enteredFs) { showFsButton(true); } if (document.fullscreenElement) showFsButton(false); } function showFsButton(on) { var b = document.getElementById('rg-session-fs'); if (b) b.hidden = !on; if (!on) { var f = document.getElementById('rg-session-frame'); try { if (f) f.focus(); } catch (e) {} } } window.addEventListener('message', onMsg); document.addEventListener('fullscreenchange', onFsChange); document.getElementById('rg-session-exit').addEventListener('click', teardown); document.getElementById('rg-session-fs').addEventListener('click', function () { // A click is a fresh gesture, so the browser allows fullscreen again. try { var p = req && req.call(el); if (p && p.catch) p.catch(function () {}); } catch (e) {} }); // Create the session at the measured viewport size — the size the // frame paints into — so there is nothing to resize afterwards. function startConnect() { if (started || done) return; started = true; clearTimeout(settleTimer); window.removeEventListener('resize', onResize); // Window size only: the measured viewport IS the fullscreen size. // No screen_width/height — the server prefers those for a // fullscreen bitmap session, and the physical screen overshoots // the viewport on a notched Mac (that was the RDP resize). var body = { dpi: Math.round((window.devicePixelRatio || 1) * 96), width: window.innerWidth, height: window.innerHeight }; fetch('/api/addressbook/folders/' + encodeURIComponent(scope) + '/' + encodeURIComponent(folder) + '/entries/' + encodeURIComponent(name) + '/connect', { method: 'POST', headers: headers({ 'Content-Type': 'application/json' }), credentials: 'same-origin', body: JSON.stringify(body) }) .then(function (res) { if (!res.ok) { if (res.status === 412) { teardown(); openFolder(scope, folder); return null; } return res.text().then(function (t) { throw new Error(t); }); } return res.json(); }) .then(function (data) { if (!data) return; var fr = document.getElementById('rg-session-frame'); if (!fr) return; fr.src = data.client_url + '?name=' + encodeURIComponent(name) + '&embedded=1'; // Fallback: hide the spinner if 'ready' never arrives. setTimeout(function () { var sp = document.getElementById('rg-session-spinner'); if (sp) sp.style.display = 'none'; }, 8000); }) .catch(function (err) { teardown(); if (window.rgAlert) rgAlert('Connect failed: ' + err.message); }); } // Whatever happens, never leave the user on a spinner: connect at the // current size if nothing has settled it within 2.5s. setTimeout(startConnect, 2500); } window.rgLaunchFullscreen = rgLaunchFullscreen; function openFolder(scope, path) { if (typeof window.rgSelectFolder === 'function') { close(); window.rgSelectFolder(scope, path); return; } location.href = '/connections.html#open=' + encodeURIComponent(scope + '/' + path); } function wire() { var inp = input(); inp.addEventListener('input', runSearch); inp.addEventListener('focus', function () { setEngaged(true); }); // The overlay closes on any mousedown that is not on the search box // or inside the results panel (so the backdrop, the nav, the logo, // anywhere else all close it), and interacting with the results or // the box keeps it open. Capture phase so it runs before result // clicks are handled. document.addEventListener('mousedown', function (e) { if (!searchEngaged) return; var gs = document.getElementById('global-search'); var panel = document.getElementById('search-overlay-panel'); if ((gs && gs.contains(e.target)) || (panel && panel.contains(e.target))) return; close(); }, true); document.getElementById('search-results').addEventListener('click', function (e) { var t = e.target; var open = t.closest && t.closest('.search-open-folder'); if (open) { e.preventDefault(); openFolder(open.getAttribute('data-open-scope'), open.getAttribute('data-open-folder')); return; } var conn = t.closest && t.closest('[data-sc-connect]'); if (conn) { e.preventDefault(); var sc = conn.getAttribute('data-scope'), fo = conn.getAttribute('data-folder'), nm = conn.getAttribute('data-sc-connect'); var row = null, si = searchIndex || []; for (var k = 0; k < si.length; k++) { if (si[k].scope === sc && si[k].folderPath === fo && si[k].entry.name === nm) { row = si[k]; break; } } var fsoc = row && row.entry && row.entry.fullscreen_on_connect; // The regular Connect goes zero-click fullscreen in this tab; the // ⧉ button is an explicit "open in a new window", so it keeps the // new-window path. if (fsoc && !conn.classList.contains('btn-connect-win')) { close(); rgLaunchFullscreen(sc, fo, nm, conn); } else doConnect(sc, fo, nm, conn, conn.classList.contains('btn-connect-win')); } }); // Escape closes the overlay from anywhere (not only the search box); // capture phase so it runs before page-level handlers. If a facet menu // is open inside the overlay, let filters.js close just that first. document.addEventListener('keydown', function (e) { if (e.key !== 'Escape') return; if (!searchEngaged && !overlay().classList.contains('open')) return; if (document.querySelector('#search-overlay .rg-facet.open')) return; // let the facet menu close first e.preventDefault(); e.stopPropagation(); close(); }, true); // "/" focuses the search unless typing in a field or a modal is open. document.addEventListener('keydown', function (e) { if (e.key !== '/' || e.ctrlKey || e.metaKey || e.altKey) return; var t = document.activeElement; if (t) { var tag = t.tagName; if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || t.isContentEditable) return; } if (document.querySelector('.modal-overlay.active')) return; if (inp.disabled) return; e.preventDefault(); e.stopPropagation(); setTimeout(function () { inp.focus(); inp.select(); }, 0); }); } var ROLE_LEVEL = { admin: 4, poweruser: 3, operator: 2, viewer: 1 }; var MIN_LEVEL = 2; // the search endpoints answer operator and above // Who the served page says it is for. Missing (a static copy with no // server behind it) is unknown, not anonymous. function servedLevel() { var v = document.documentElement.getAttribute('data-role-level'); return v == null || v === '' ? null : (parseInt(v, 10) || 0); } // Ask the server who the caller is. Resolves to an identity string, or // null when the caller is not signed in or is below operator. function whoAmI() { return fetch('/api/me', { headers: headers(), credentials: 'same-origin' }) .then(function (r) { return r.ok ? r.json() : null; }) .then(function (me) { if (!me || !me.role || (ROLE_LEVEL[me.role] || 0) < MIN_LEVEL) return null; confirmedLevel = ROLE_LEVEL[me.role]; return (me.name || '') + '|' + me.role + '|' + (me.groups || []).join(','); }) .catch(function () { return null; }); } function start() { // Logout from any page: forget the index before the login page shows. document.addEventListener('click', function (e) { var t = e.target && e.target.closest ? e.target.closest('#logout-item') : null; if (t) dropCache(); }, true); var level = servedLevel(); // The server wrote anonymous or viewer on the page and no API key is in // hand: nothing to search with, nothing to show. Also sweep any index // a previous login left in this tab. if (level != null && level < MIN_LEVEL && !apiKey()) { dropCache(); return; } if (!document.querySelector('.brand-row')) return; // Operator or above by the server's word: paint the box now so the // header is right on first paint, and confirm the identity in the // background. Anyone else (API key, or a page with no role written on // it) is confirmed first and gets the box only on a yes. if (level != null && level >= MIN_LEVEL) { injectUI(); injectAdhoc(level); whoAmI().then(function (who) { if (!who) { signedOut(); return; } // The server's answer wins over the page's. if (confirmedLevel < ADHOC_LEVEL) removeAdhoc(); else injectAdhoc(confirmedLevel); boot(who); }); } else { whoAmI().then(function (who) { if (!who) { dropCache(); return; } injectUI(); injectAdhoc(confirmedLevel); boot(who); }); } } function boot(who) { // injectUI only creates the box when a .brand-row exists; without it // there is nothing to wire, so stand down rather than throw. if (!input()) return; var mkFilters = function () { searchFilters = window.rgFilters.create({ mount: document.getElementById('search-facets'), id: 'global-search', facets: [ { key: 'type', label: 'Type', value: function (x) { return x.entry.session_type; }, render: function (v) { return '' + esc(v) + ''; } }, { key: 'folder', label: 'Folder', value: function (x) { return x.folderPath || null; } } ], onChange: function () { runSearch(); } }); wire(); loadIndex(who); }; if (window.rgFilters) { mkFilters(); } else { var s = document.createElement('script'); s.src = '/filters.js'; s.onload = mkFilters; document.head.appendChild(s); } } if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start); else start(); })();