import { decryptPortalStream, validatePortalPayload } from '../utils/portal-crypto.mjs'; import { portalLinkPresentation } from '../utils/portal-presentation'; /** Build a passive DOM, never inject executable decrypted HTML. Remote media, * embedded forms and styles require owner review and are not loaded on unlock. */ export function safePortalContent(html: string): DocumentFragment { const template = document.createElement('template'); template.innerHTML = html; const allowed = new Set(['DIV','SECTION','ARTICLE','HEADER','FOOTER','H1','H2','H3','H4','H5','H6','P','SPAN','STRONG','EM','B','I','U','SMALL','BR','HR','UL','OL','LI','DL','DT','DD','TABLE','THEAD','TBODY','TR','TH','TD','CAPTION','A']); const output = document.createDocumentFragment(); const copy = (source: Node, target: Node) => { if (source.nodeType === Node.TEXT_NODE) { target.appendChild(document.createTextNode(source.textContent ?? '')); return; } if (!(source instanceof HTMLElement)) return; if (!allowed.has(source.tagName)) return; const element = document.createElement(source.tagName.toLowerCase()); if (source.tagName === 'A') { const href = source.getAttribute('href') ?? ''; try { const url = new URL(href, location.origin); if (['https:', 'mailto:', 'tel:'].includes(url.protocol) || (url.origin === location.origin && url.protocol === location.protocol)) { element.setAttribute('href', url.href); element.setAttribute('rel', 'noopener noreferrer'); element.setAttribute('referrerpolicy', 'no-referrer'); if (url.origin !== location.origin && url.protocol === 'https:') element.setAttribute('target', '_blank'); const presentation = portalLinkPresentation([...source.classList], source.style.backgroundColor, url.protocol, source.textContent ?? ''); if (presentation) element.className = presentation; } } catch { /* Invalid destination remains visible text without navigation. */ } } // Preserve directory semantics without retaining arbitrary legacy attributes. if (source.tagName === 'TH' && ['row', 'col', 'rowgroup', 'colgroup'].includes(source.getAttribute('scope') ?? '')) { element.setAttribute('scope', source.getAttribute('scope')!); } if (['TD', 'TH'].includes(source.tagName)) { for (const attr of ['colspan', 'rowspan']) { const value = source.getAttribute(attr); if (value && /^[1-9]\d?$/.test(value)) element.setAttribute(attr, value); } } for (const child of source.childNodes) copy(child, element); target.appendChild(element); }; for (const child of template.content.childNodes) copy(child, output); enhanceStaffPortalPresentation(output); return output; } /** * The staff stream has a "Management Templates" section. Its resource links * used HubSpot presentation rules that are intentionally removed by the safe * renderer. Restore local visual semantics only after sanitization; never * copy arbitrary source classes, attributes, styles or executable content. */ function enhanceStaffPortalPresentation(fragment: DocumentFragment): void { const headings = [...fragment.querySelectorAll('h1, h2, h3, h4, h5, h6')]; const templatesHeading = headings.find((heading) => heading.textContent?.replace(/\s+/g, ' ').trim().toLowerCase() === 'management templates'); if (!templatesHeading) return; // This heading distinguishes the general-staff resource directory from the // separate management-report view. Preserve contact links as links. for (const anchor of fragment.querySelectorAll('a[href]')) { const protocol = new URL(anchor.href).protocol; if (protocol === 'https:' || protocol === location.protocol) { anchor.classList.add('portal-resource-button'); } } // The original section is a heading followed by a list, occasionally with // intervening wrappers. Keep the list and its reading/tab order intact. let templatesList: HTMLUListElement | null = null; for (let sibling = templatesHeading.nextElementSibling; sibling; sibling = sibling.nextElementSibling) { if (/^H[1-6]$/.test(sibling.tagName)) break; if (sibling instanceof HTMLUListElement) { templatesList = sibling; break; } templatesList = sibling.querySelector('ul'); if (templatesList) break; } if (!templatesList) templatesList = templatesHeading.closest('section, article')?.querySelector('ul') ?? null; templatesList?.classList.add('portal-template-grid'); } function initialize() { const root = document.querySelector('[data-employee-portal]'); if (!root || root.dataset.bound) return; root.dataset.bound = 'true'; const input = root.querySelector('[data-portal-code]')!; const button = root.querySelector('button')!; const feedback = root.querySelector('[data-portal-feedback]')!; const prompt = root.querySelector('[data-portal-prompt]')!; const content = root.querySelector('[data-portal-content]')!; let data: ReturnType; try { if (!crypto.subtle) throw new Error(); data = validatePortalPayload(JSON.parse(root.querySelector('[data-portal-payload]')?.textContent ?? 'null')); input.disabled = false; button.disabled = false; feedback.textContent = ''; } catch { feedback.textContent = 'Portal access is unavailable in this preview. Please contact AIA.'; return; } const unlock = async () => { if (button.disabled) return; button.disabled = true; input.setAttribute('aria-invalid', 'false'); feedback.textContent = ''; let pass = input.value; try { for (const stream of data.streams) { const plain = await decryptPortalStream(stream, pass); if (plain !== null) { content.replaceChildren(safePortalContent(plain)); prompt.hidden = true; content.hidden = false; input.value = ''; content.focus(); return; } } input.setAttribute('aria-invalid', 'true'); feedback.textContent = 'Invalid Code'; input.focus(); } catch { feedback.textContent = 'Portal access is unavailable. Please try again or contact AIA.'; } finally { pass = ''; button.disabled = false; } }; button.addEventListener('click', unlock); input.addEventListener('keydown', (event) => { if (event.key === 'Enter') { event.preventDefault(); void unlock(); } }); // BFCache must not restore an already unlocked view. addEventListener('pagehide', () => { input.value = ''; content.replaceChildren(); content.hidden = true; prompt.hidden = false; }); } initialize(); document.addEventListener('astro:page-load', initialize);