122 lines
6.6 KiB
TypeScript
122 lines
6.6 KiB
TypeScript
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<HTMLElement>('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<HTMLAnchorElement>('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<HTMLElement>('[data-employee-portal]');
|
|
if (!root || root.dataset.bound) return;
|
|
root.dataset.bound = 'true';
|
|
const input = root.querySelector<HTMLInputElement>('[data-portal-code]')!;
|
|
const button = root.querySelector<HTMLButtonElement>('button')!;
|
|
const feedback = root.querySelector<HTMLElement>('[data-portal-feedback]')!;
|
|
const prompt = root.querySelector<HTMLElement>('[data-portal-prompt]')!;
|
|
const content = root.querySelector<HTMLElement>('[data-portal-content]')!;
|
|
let data: ReturnType<typeof validatePortalPayload>;
|
|
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);
|