293 lines
12 KiB
JavaScript
293 lines
12 KiB
JavaScript
(function (root, factory) {
|
||
if (typeof module !== 'undefined' && module.exports) module.exports = factory;
|
||
else {
|
||
const base = new URL('./', root.location.href).pathname;
|
||
const originalFetch = root.fetch.bind(root);
|
||
const boundary = factory({
|
||
cookie: () => root.document.cookie,
|
||
origin: root.location.origin,
|
||
base,
|
||
fetchImpl: originalFetch,
|
||
localStorage: root.localStorage,
|
||
sessionStorage: root.sessionStorage,
|
||
indexedDB: root.indexedDB,
|
||
caches: root.caches,
|
||
serviceWorker: root.navigator?.serviceWorker,
|
||
MessageChannel: root.MessageChannel,
|
||
location: root.location,
|
||
confirmAction: message => root.confirm(message),
|
||
promptAuthorization: details => root.prompt(
|
||
`Confirm ${String(details.action || 'this action').replaceAll('_', ' ')} by entering your dashboard access token.`,
|
||
),
|
||
onExpired: () => root.dispatchEvent(new CustomEvent('stackchain:session-expired')),
|
||
onClearError: error => {
|
||
root.dispatchEvent(new CustomEvent('stackchain:device-clear-failed', { detail: error.message }));
|
||
root.alert('Signed out, but private queued work could not be cleared. Close other Stackchain tabs and clear this site’s data.');
|
||
},
|
||
});
|
||
root.fetch = boundary.fetch;
|
||
const attach = () => {
|
||
const button = root.document.getElementById('sign-out');
|
||
if (button) button.addEventListener('click', () => boundary.signOut());
|
||
const allDevicesButton = root.document.getElementById('sign-out-all');
|
||
if (allDevicesButton) allDevicesButton.addEventListener('click', () => boundary.signOutAllDevices());
|
||
const devicesButton = root.document.getElementById('active-devices');
|
||
const devicesSheet = root.document.getElementById('active-devices-sheet');
|
||
const devicesList = root.document.getElementById('active-devices-list');
|
||
const devicesStatus = root.document.getElementById('active-devices-status');
|
||
const closeDevices = root.document.getElementById('close-active-devices');
|
||
const renderDevices = async () => {
|
||
devicesStatus.textContent = 'Loading active devices…';
|
||
devicesList.replaceChildren();
|
||
try {
|
||
const devices = await boundary.listActiveDevices();
|
||
devices.forEach(device => {
|
||
const row = root.document.createElement('article');
|
||
row.className = 'active-device';
|
||
const details = root.document.createElement('div');
|
||
const label = root.document.createElement('strong');
|
||
label.textContent = device.device_label;
|
||
const timing = root.document.createElement('span');
|
||
timing.className = 'small muted';
|
||
timing.textContent = `Signed in ${new Date(device.created_at * 1000).toLocaleString()} · expires ${new Date(device.expires_at * 1000).toLocaleString()}`;
|
||
details.append(label, timing);
|
||
if (device.current) {
|
||
const current = root.document.createElement('span');
|
||
current.className = 'active-device-current';
|
||
current.textContent = 'This device';
|
||
details.append(current);
|
||
}
|
||
const revoke = root.document.createElement('button');
|
||
revoke.type = 'button';
|
||
revoke.textContent = device.current ? 'Sign out' : 'Revoke';
|
||
revoke.addEventListener('click', async () => {
|
||
if (device.current) await boundary.signOut();
|
||
else if (await boundary.revokeActiveDevice(device)) await renderDevices();
|
||
});
|
||
row.append(details, revoke);
|
||
devicesList.append(row);
|
||
});
|
||
devicesStatus.textContent = devices.length ? `${devices.length} active device${devices.length === 1 ? '' : 's'}` : 'No active devices.';
|
||
} catch (_error) {
|
||
devicesStatus.textContent = 'Active devices could not be loaded. Try again.';
|
||
}
|
||
};
|
||
if (devicesButton && devicesSheet) devicesButton.addEventListener('click', () => {
|
||
devicesSheet.hidden = false;
|
||
closeDevices?.focus();
|
||
renderDevices();
|
||
});
|
||
if (closeDevices && devicesSheet) closeDevices.addEventListener('click', () => {
|
||
devicesSheet.hidden = true;
|
||
devicesButton?.focus();
|
||
});
|
||
boundary.resumeQueuedWork();
|
||
};
|
||
root.navigator?.serviceWorker?.addEventListener?.('message', event => boundary.handleServiceWorkerMessage(event));
|
||
if (root.document.readyState === 'loading') root.document.addEventListener('DOMContentLoaded', attach);
|
||
else attach();
|
||
root.stackchainSession = boundary;
|
||
}
|
||
})(typeof window !== 'undefined' ? window : this, function createSessionBoundary({
|
||
cookie, origin, base, fetchImpl, localStorage, sessionStorage, indexedDB, caches, serviceWorker, MessageChannel, location, confirmAction,
|
||
promptAuthorization = () => null,
|
||
onExpired = () => {},
|
||
onClearError = () => {},
|
||
}) {
|
||
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
||
let expirationStarted = false;
|
||
|
||
async function handleRemoteRevocation() {
|
||
if (expirationStarted) return;
|
||
expirationStarted = true;
|
||
await clearPrivateDeviceData();
|
||
location.replace(base + 'login?reason=session-revoked');
|
||
}
|
||
|
||
async function handleServiceWorkerMessage(event) {
|
||
if (event?.data?.type === 'stackchain-session-revoked') {
|
||
await handleRemoteRevocation();
|
||
}
|
||
}
|
||
|
||
function csrfToken() {
|
||
const entry = String(cookie?.() || '').split(';')
|
||
.map(value => value.trim())
|
||
.find(value => value.startsWith('stackchain_csrf='));
|
||
return entry ? decodeURIComponent(entry.slice('stackchain_csrf='.length)) : '';
|
||
}
|
||
|
||
function isSameOrigin(input) {
|
||
try { return new URL(String(input?.url || input), origin).origin === origin; }
|
||
catch (_error) { return false; }
|
||
}
|
||
|
||
async function sessionFetch(input, options = {}, allowStepUp = true) {
|
||
const method = String(options.method || input?.method || 'GET').toUpperCase();
|
||
const requestOptions = { ...options };
|
||
if (!SAFE_METHODS.has(method) && isSameOrigin(input)) {
|
||
const headers = new Headers(options.headers || input?.headers || {});
|
||
const csrf = csrfToken();
|
||
if (csrf) headers.set('X-CSRF-Token', csrf);
|
||
requestOptions.headers = headers;
|
||
}
|
||
const response = await fetchImpl(input, requestOptions);
|
||
if (response.status === 428 && isSameOrigin(input) && allowStepUp) {
|
||
const payload = await response.clone().json().catch(() => ({}));
|
||
const detail = payload?.detail || {};
|
||
if (detail.code === 'step_up_required' && detail.action && detail.target) {
|
||
const accessToken = await promptAuthorization({
|
||
action: detail.action,
|
||
target: detail.target,
|
||
});
|
||
if (!accessToken) return response;
|
||
const authorizationHeaders = new Headers({
|
||
Accept: 'application/json',
|
||
'Content-Type': 'application/json',
|
||
});
|
||
const csrf = csrfToken();
|
||
if (csrf) authorizationHeaders.set('X-CSRF-Token', csrf);
|
||
const authorization = await fetchImpl(base + 'api/v1/fresh-authorization', {
|
||
method: 'POST',
|
||
headers: authorizationHeaders,
|
||
body: JSON.stringify({
|
||
access_token: accessToken,
|
||
action: detail.action,
|
||
target: detail.target,
|
||
}),
|
||
});
|
||
if (!authorization.ok) return authorization;
|
||
const grant = await authorization.json().catch(() => ({}));
|
||
if (!grant.grant) return response;
|
||
const retryHeaders = new Headers(requestOptions.headers || input?.headers || {});
|
||
retryHeaders.set('X-Step-Up-Grant', grant.grant);
|
||
return sessionFetch(input, { ...requestOptions, headers: retryHeaders }, false);
|
||
}
|
||
}
|
||
if (response.status === 401 && isSameOrigin(input) && !expirationStarted) {
|
||
expirationStarted = true;
|
||
const payload = await response.clone().json().catch(() => ({}));
|
||
if (payload.code === 'session_revoked') {
|
||
expirationStarted = false;
|
||
await handleRemoteRevocation();
|
||
} else {
|
||
onExpired();
|
||
location.replace(base + 'login?reason=session-expired');
|
||
}
|
||
}
|
||
return response;
|
||
}
|
||
|
||
function removeDashboardStorage(storage) {
|
||
if (!storage) return;
|
||
const keys = [];
|
||
try {
|
||
for (let index = 0; index < storage.length; index += 1) {
|
||
const key = storage.key(index);
|
||
if (key?.startsWith('stackchain.')) keys.push(key);
|
||
}
|
||
keys.forEach(key => storage.removeItem(key));
|
||
} catch (_error) { /* Cookie invalidation still protects server data. */ }
|
||
}
|
||
|
||
async function stopWorkerOutbox() {
|
||
const registration = await serviceWorker?.ready;
|
||
if (!registration?.active || !MessageChannel) return;
|
||
await new Promise((resolve, reject) => {
|
||
const channel = new MessageChannel();
|
||
const timeout = setTimeout(() => reject(new Error('Background outbox purge timed out.')), 3000);
|
||
channel.port1.onmessage = event => {
|
||
clearTimeout(timeout);
|
||
if (event.data?.ok) resolve();
|
||
else reject(new Error(event.data?.error || 'Background outbox purge failed.'));
|
||
};
|
||
registration.active.postMessage({ type: 'stackchain-purge-outbox' }, [channel.port2]);
|
||
});
|
||
}
|
||
|
||
function deletePrivateOutbox() {
|
||
if (!indexedDB) return Promise.resolve();
|
||
return new Promise((resolve, reject) => {
|
||
let request;
|
||
try { request = indexedDB.deleteDatabase('stackchain-background-outbox-v1'); }
|
||
catch (error) { reject(error); return; }
|
||
request.onsuccess = () => resolve();
|
||
request.onerror = () => reject(request.error || new Error('IndexedDB deletion failed.'));
|
||
request.onblocked = () => reject(new Error('IndexedDB deletion was blocked.'));
|
||
});
|
||
}
|
||
|
||
async function clearPrivateDeviceData() {
|
||
removeDashboardStorage(localStorage);
|
||
if (sessionStorage !== localStorage) removeDashboardStorage(sessionStorage);
|
||
try {
|
||
await stopWorkerOutbox();
|
||
await deletePrivateOutbox();
|
||
} catch (_error) {
|
||
const error = new Error('Could not clear private queued work from this device.');
|
||
onClearError(error);
|
||
throw error;
|
||
}
|
||
try {
|
||
const keys = await caches?.keys?.() || [];
|
||
await Promise.all(keys.filter(key => key.startsWith('stackchain-dashboard-')).map(key => caches.delete(key)));
|
||
} catch (_error) { /* A later service-worker activation can clear stale caches. */ }
|
||
}
|
||
|
||
async function listActiveDevices() {
|
||
const response = await sessionFetch(base + 'api/v1/sessions');
|
||
if (!response.ok) throw new Error('Could not load active devices');
|
||
const payload = await response.json();
|
||
return Array.isArray(payload.devices) ? payload.devices : [];
|
||
}
|
||
|
||
async function revokeActiveDevice(device) {
|
||
if (!device?.management_id || device.current) return false;
|
||
const confirmed = confirmAction?.(`Sign out ${device.device_label}?`);
|
||
if (!confirmed) return false;
|
||
const response = await sessionFetch(
|
||
base + 'api/v1/sessions/' + encodeURIComponent(device.management_id),
|
||
{ method: 'DELETE' },
|
||
);
|
||
if (!response.ok) throw new Error('Could not revoke active device');
|
||
return true;
|
||
}
|
||
|
||
async function signOut() {
|
||
try {
|
||
await sessionFetch(base + 'api/v1/session', { method: 'DELETE' });
|
||
} finally {
|
||
await clearPrivateDeviceData();
|
||
location.assign(base + 'login');
|
||
}
|
||
}
|
||
|
||
async function signOutAllDevices() {
|
||
const confirmed = confirmAction?.('Sign out every device? You will need to sign in again everywhere.');
|
||
if (!confirmed) return false;
|
||
const response = await sessionFetch(base + 'api/v1/sessions', { method: 'DELETE' });
|
||
if (!response.ok) throw new Error('Could not sign out all devices');
|
||
await clearPrivateDeviceData();
|
||
location.assign(base + 'login');
|
||
return true;
|
||
}
|
||
|
||
async function resumeQueuedWork() {
|
||
try {
|
||
const registration = await serviceWorker?.ready;
|
||
registration?.active?.postMessage({ type: 'stackchain-resume-outbox' });
|
||
} catch (_error) { /* Background Sync is optional; foreground delivery remains available. */ }
|
||
}
|
||
|
||
return {
|
||
fetch: sessionFetch,
|
||
signOut,
|
||
signOutAllDevices,
|
||
listActiveDevices,
|
||
revokeActiveDevice,
|
||
clearPrivateDeviceData,
|
||
handleServiceWorkerMessage,
|
||
resumeQueuedWork,
|
||
};
|
||
});
|