feat(portal): reuse unlock code for protected downloads

This commit is contained in:
DeCentN2Madness authored and AIAMarketing committed 2026-11-05 19:21:26 -07:00
1 parent 475dd3379b
commit 596246f20a
9 files changed
+268 -13

No files matched your search

@@ -25,6 +25,7 @@ const serialized = payload ? JSON.stringify(payload).replaceAll('<', '\\u003c')
<noscript><p>JavaScript is required to unlock the employee portal locally. Please contact AIA for access assistance.</p></noscript>
</div>
<div class="employee-portal__content" tabindex="-1" role="region" aria-label="Unlocked employee portal" hidden data-portal-content></div>
<p class="employee-portal__download-feedback" role="status" aria-live="polite" hidden data-portal-download-feedback></p>
<script is:inline type="application/json" data-portal-payload set:html={serialized}></script>
</section>
@@ -35,6 +36,7 @@ const serialized = payload ? JSON.stringify(payload).replaceAll('<', '\\u003c')
input { background: #fcf9f5; border: 1px solid #edeef3; border-radius: 4px; font: inherit; padding: .65rem; max-width: 100%; min-width: 0; }
input:focus-visible { outline: 3px solid var(--color-focus, #115bbd); outline-offset: 3px; }
.employee-portal__content:focus { outline: none; }
.employee-portal__download-feedback { margin-block: 1rem; font-weight: 600; }
[hidden] { display: none; }
.employee-portal__content :global(a) { color: var(--color-primary); }
.employee-portal__content :global(a:focus-visible) { outline: 3px solid var(--color-focus, #115bbd); outline-offset: 3px; }
+52 -2
View File
@@ -1,4 +1,5 @@
import { decryptPortalStream, validatePortalPayload } from '../utils/portal-crypto.mjs';
import { fetchProtectedPortalResource, portalDownloadErrorMessage, portalRoleForStreamId, protectedPortalResource } from '../utils/portal-download.mjs';
import { portalLinkPresentation } from '../utils/portal-presentation';
/** Build a passive DOM, never inject executable decrypted HTML. Remote media,
@@ -24,6 +25,8 @@ export function safePortalContent(html: string): DocumentFragment {
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;
const resource = protectedPortalResource(url.href, location.origin);
if (resource) element.dataset.portalResourceRole = resource.role;
}
} catch { /* Invalid destination remains visible text without navigation. */ }
}
@@ -99,6 +102,9 @@ function initialize() {
const feedback = root.querySelector<HTMLElement>('[data-portal-feedback]')!;
const prompt = root.querySelector<HTMLElement>('[data-portal-prompt]')!;
const content = root.querySelector<HTMLElement>('[data-portal-content]')!;
const downloadFeedback = root.querySelector<HTMLElement>('[data-portal-download-feedback]')!;
let credential: { role: 'staff' | 'management'; code: string } | null = null;
let downloading = false;
let data: ReturnType<typeof validatePortalPayload>;
try {
if (!crypto.subtle) throw new Error();
@@ -106,6 +112,45 @@ function initialize() {
input.disabled = false; button.disabled = false;
feedback.textContent = '';
} catch { feedback.textContent = 'Portal access is unavailable in this preview. Please contact AIA.'; return; }
const clearSession = () => {
credential = null;
input.value = '';
content.replaceChildren();
content.hidden = true;
prompt.hidden = false;
downloadFeedback.hidden = true;
downloadFeedback.textContent = '';
};
const saveDownload = (blob: Blob, filename: string) => {
const objectUrl = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = objectUrl; link.download = filename; link.hidden = true;
document.body.append(link); link.click(); link.remove();
setTimeout(() => URL.revokeObjectURL(objectUrl), 0);
};
content.addEventListener('click', (event) => {
const target = event.target instanceof Element ? event.target.closest<HTMLAnchorElement>('a[data-portal-resource-role]') : null;
if (!target) return;
event.preventDefault();
if (downloading) return;
const resource = protectedPortalResource(target.href, location.origin);
if (!resource || !credential) {
downloadFeedback.textContent = portalDownloadErrorMessage(undefined);
downloadFeedback.hidden = false;
return;
}
downloading = true;
target.setAttribute('aria-disabled', 'true');
downloadFeedback.textContent = 'Downloading document…';
downloadFeedback.hidden = false;
void fetchProtectedPortalResource(resource, credential)
.then(({ blob, filename }) => {
saveDownload(blob, filename);
downloadFeedback.textContent = `Downloaded ${filename}.`;
})
.catch((error) => { downloadFeedback.textContent = portalDownloadErrorMessage(error); })
.finally(() => { downloading = false; target.removeAttribute('aria-disabled'); });
});
const unlock = async () => {
if (button.disabled) return;
button.disabled = true;
@@ -116,8 +161,12 @@ function initialize() {
for (const stream of data.streams) {
const plain = await decryptPortalStream(stream, pass);
if (plain !== null) {
const role = portalRoleForStreamId(stream.id);
if (!role) throw new Error('Unknown portal stream');
credential = { role, code: pass };
content.replaceChildren(safePortalContent(plain));
prompt.hidden = true; content.hidden = false;
downloadFeedback.hidden = true; downloadFeedback.textContent = '';
input.value = ''; content.focus(); return;
}
}
@@ -127,8 +176,9 @@ function initialize() {
};
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; });
// Credentials exist only in this page closure and are discarded on exit/swap.
addEventListener('pagehide', clearSession, { once: true });
document.addEventListener('astro:before-swap', clearSession, { once: true });
}
initialize();
document.addEventListener('astro:page-load', initialize);
+70
View File
@@ -0,0 +1,70 @@
const roles = new Set(['staff', 'management']);
const resourcePath = /^\/(staff|management)-resources\/([A-Za-z0-9][A-Za-z0-9._-]{0,240})$/;
export function portalRoleForStreamId(id) {
if (id === 'b') return 'staff';
if (id === 'a') return 'management';
return undefined;
}
export function protectedPortalResource(href, origin) {
let url;
try { url = new URL(href, origin); } catch { return undefined; }
if (url.origin !== origin || url.username || url.password || url.search || url.hash) return undefined;
const match = resourcePath.exec(url.pathname);
if (!match) return undefined;
return { role: match[1], filename: match[2], url: url.href };
}
function basicAuthorization(role, code) {
if (!roles.has(role) || typeof code !== 'string' || !code || code.length > 1024 || /[\r\n\0]/.test(code)) {
throw new PortalDownloadError('session');
}
const bytes = new TextEncoder().encode(`${role}:${code}`);
let binary = '';
for (const byte of bytes) binary += String.fromCharCode(byte);
return `Basic ${btoa(binary)}`;
}
function safeFilename(value, fallback) {
if (typeof value !== 'string') return fallback;
const name = value.trim();
return /^[A-Za-z0-9][A-Za-z0-9._-]{0,240}$/.test(name) ? name : fallback;
}
function responseFilename(response, fallback) {
const disposition = response.headers.get('content-disposition') ?? '';
const encoded = disposition.match(/filename\*=UTF-8''([^;]+)/i)?.[1];
if (encoded) {
try { return safeFilename(decodeURIComponent(encoded), fallback); } catch { /* Use URL filename. */ }
}
const quoted = disposition.match(/filename="([^"]+)"/i)?.[1];
return safeFilename(quoted, fallback);
}
export class PortalDownloadError extends Error {
constructor(code) { super(code); this.name = 'PortalDownloadError'; this.code = code; }
}
export async function fetchProtectedPortalResource(resource, credential, fetcher = fetch) {
if (!resource || !credential || resource.role !== credential.role) throw new PortalDownloadError('role');
const response = await fetcher(resource.url, {
method: 'GET',
headers: { Authorization: basicAuthorization(credential.role, credential.code) },
credentials: 'omit',
cache: 'no-store',
redirect: 'error',
referrerPolicy: 'no-referrer',
});
if (response.status === 401 || response.status === 403) throw new PortalDownloadError('unauthorized');
if (!response.ok) throw new PortalDownloadError('unavailable');
return { blob: await response.blob(), filename: responseFilename(response, resource.filename) };
}
export function portalDownloadErrorMessage(error) {
if (error instanceof PortalDownloadError && error.code === 'role') return 'This document is not available from this portal.';
if (error instanceof PortalDownloadError && (error.code === 'unauthorized' || error.code === 'session')) {
return 'Your portal session could not authorize this download. Reload the page and enter the access code again.';
}
return 'The document could not be downloaded. Please try again or contact AIA.';
}