218 lines
9.5 KiB
JavaScript
218 lines
9.5 KiB
JavaScript
// One verified owner per document. Never unlock: a new account needs a new JS realm.
|
|
(function() {
|
|
var KEY = 'ped_account_boundary_v1';
|
|
var owner = null, generation = null, locked = false, revision = 0;
|
|
var controller = new AbortController();
|
|
var channel = null;
|
|
var storageFailed = false;
|
|
var signInRequired = false;
|
|
var SIGN_IN_KEY = 'ped_signin_required';
|
|
function needsSignIn() {
|
|
try { return sessionStorage.getItem(SIGN_IN_KEY) === '1'; }
|
|
catch (e) { return true; }
|
|
}
|
|
function recoverSignIn() {
|
|
signInRequired = true;
|
|
freeze();
|
|
document.querySelector('#account-recovery p').textContent =
|
|
'Your saved credentials no longer match this session. Sign in again in a fresh page.';
|
|
document.querySelector('#account-recovery button').textContent = 'Sign in again';
|
|
}
|
|
function read() {
|
|
try { return JSON.parse(localStorage.getItem(KEY) || 'null'); }
|
|
catch (e) { storageFailed = true; return null; }
|
|
}
|
|
function publish(next) {
|
|
try {
|
|
var value = JSON.stringify(next);
|
|
localStorage.setItem(KEY, value);
|
|
if (localStorage.getItem(KEY) !== value) throw new Error('Account isolation write not confirmed');
|
|
storageFailed = false;
|
|
}
|
|
catch (e) { storageFailed = true; freeze(); throw new Error('Account isolation storage unavailable'); }
|
|
try { if (channel) channel.postMessage(next); } catch (e) {}
|
|
return next;
|
|
}
|
|
function fresh(nextOwner) {
|
|
return { owner: nextOwner, generation: crypto.randomUUID(), signedOut: !nextOwner };
|
|
}
|
|
function abortError() { return new DOMException('Account changed; reload required', 'AbortError'); }
|
|
function reload() {
|
|
// A failed publication is only realm-local until a durable signed-out latch
|
|
// exists. Never discard that failure by navigating back to surviving auth.
|
|
if (storageFailed) {
|
|
freeze();
|
|
try { publish(fresh(null)); }
|
|
catch (e) {
|
|
document.querySelector('#account-recovery p').textContent =
|
|
'Cannot safely reload: session storage is unavailable. Restore storage access, then retry Reload.';
|
|
return;
|
|
}
|
|
}
|
|
if (signInRequired) {
|
|
// Realm-local recovery must not erase a newer sibling's owner/credentials.
|
|
try {
|
|
sessionStorage.setItem(SIGN_IN_KEY, '1');
|
|
if (sessionStorage.getItem(SIGN_IN_KEY) !== '1') throw new Error('Sign-in recovery write not confirmed');
|
|
} catch (e) {
|
|
document.querySelector('#account-recovery p').textContent =
|
|
'Cannot safely reload: session storage is unavailable. Restore storage access, then retry Sign in again.';
|
|
return;
|
|
}
|
|
}
|
|
try { window.location.reload(); } catch (e) { /* Recovery stays available. */ }
|
|
}
|
|
function freeze() {
|
|
if (locked) return;
|
|
locked = true;
|
|
revision++;
|
|
controller.abort();
|
|
// CSS also hides late modal/async DOM insertions outside main-app.
|
|
document.documentElement.classList.add('account-transition');
|
|
Array.from(document.body.children).forEach(function(el) { el.inert = true; });
|
|
var notice = document.createElement('section');
|
|
notice.id = 'account-recovery';
|
|
notice.setAttribute('role', 'alert');
|
|
var text = document.createElement('p');
|
|
text.textContent = 'Your account session changed. Reload to continue safely.';
|
|
var button = document.createElement('button');
|
|
button.textContent = 'Reload';
|
|
button.addEventListener('click', reload);
|
|
notice.append(text, button);
|
|
document.body.appendChild(notice);
|
|
button.focus();
|
|
window.dispatchEvent(new Event('account-boundary'));
|
|
}
|
|
function current() {
|
|
if (locked) return false;
|
|
var shared = read();
|
|
if (owner && (!shared || shared.signedOut || shared.owner !== owner || shared.generation !== generation)) {
|
|
freeze();
|
|
reload();
|
|
}
|
|
return !locked && !storageFailed && !!owner;
|
|
}
|
|
function capture() { return current() ? owner : null; }
|
|
function valid(ticket) { return !!ticket && ticket === owner && current(); }
|
|
function receive(message) {
|
|
var latest = read();
|
|
// Ignore queued events from A after B has already published its session.
|
|
if (!message || !latest || message.generation !== latest.generation) return;
|
|
if (owner && (message.signedOut || message.owner !== owner || message.generation !== generation)) {
|
|
freeze();
|
|
reload(); // Sibling events must never delete another tab's newly persisted native credentials.
|
|
}
|
|
}
|
|
try { channel = new BroadcastChannel('pedscribe-auth'); channel.onmessage = function(e) { receive(e.data); }; } catch (e) {}
|
|
window.addEventListener('storage', function(e) {
|
|
if (e.key !== KEY) return;
|
|
if (!e.newValue) { if (owner) { freeze(); reload(); } return; }
|
|
try { receive(JSON.parse(e.newValue)); } catch (err) {}
|
|
});
|
|
window.addEventListener('pageshow', function(e) {
|
|
if (e.persisted) { freeze(); reload(); }
|
|
else if (owner) current();
|
|
});
|
|
// Hide before a BFCache snapshot is taken, not merely after it is restored.
|
|
window.addEventListener('pagehide', freeze);
|
|
|
|
// Preserve unowned legacy clinical data, but never read or adopt it.
|
|
// Clinical callers use storageKey() only after a verified owner enters.
|
|
|
|
window.AccountBoundary = {
|
|
key: KEY, read: read, freeze: freeze, reload: reload, capture: capture, valid: valid,
|
|
recoverSignIn: recoverSignIn, needsSignIn: needsSignIn,
|
|
completeSignIn: function() { sessionStorage.removeItem(SIGN_IN_KEY); },
|
|
active: current, blocked: function() { return locked; }, error: abortError,
|
|
revision: function() { return revision; }, signal: function() { return controller.signal; },
|
|
signedOut: function() { var state = read(); return storageFailed || !!(state && state.signedOut); },
|
|
startLogin: function() {
|
|
if (locked) throw abortError();
|
|
if (owner) freeze();
|
|
publish(fresh(null)); // Freeze siblings before a login response can replace the shared cookie.
|
|
return revision;
|
|
},
|
|
end: function() {
|
|
freeze();
|
|
var published = false;
|
|
try { publish(fresh(null)); published = true; } catch (e) { /* reload() retries the durable latch. */ }
|
|
window.AUTH_TOKEN = null;
|
|
window.CURRENT_USER = null;
|
|
return published;
|
|
},
|
|
enter: function(user, explicit) {
|
|
if (!user || user.id == null || storageFailed) { freeze(); return false; }
|
|
var nextOwner = String(user.id);
|
|
if (locked || (owner && owner !== nextOwner)) { freeze(); return false; }
|
|
if (!explicit && (this.signedOut() || needsSignIn())) return false;
|
|
var state = read();
|
|
if (storageFailed) { freeze(); return false; }
|
|
if (!explicit && state && state.owner !== nextOwner) { recoverSignIn(); return false; }
|
|
if (explicit || !state) state = publish(fresh(nextOwner));
|
|
owner = nextOwner; generation = state.generation;
|
|
window.dispatchEvent(new Event('account-ready'));
|
|
return true;
|
|
},
|
|
// Successful replacement login persists credentials first, but cannot enter this document.
|
|
publishLogin: function(user) { publish(fresh(String(user.id))); },
|
|
storageKey: function(key) {
|
|
if (!current()) throw abortError();
|
|
return key + ':owner:' + encodeURIComponent(owner);
|
|
}
|
|
};
|
|
|
|
// All actual recorder callers share these platform boundaries, including
|
|
// Notes, ED, the assistant and recorders constructed during pause/resume.
|
|
var recorders = new Set(), streams = new Set(), recognizers = new Set();
|
|
if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
|
|
var getUserMedia = navigator.mediaDevices.getUserMedia.bind(navigator.mediaDevices);
|
|
navigator.mediaDevices.getUserMedia = function(constraints) {
|
|
var ticket = capture();
|
|
if (!ticket) return Promise.reject(abortError());
|
|
return getUserMedia(constraints).then(function(stream) {
|
|
if (!valid(ticket)) { stream.getTracks().forEach(function(t) { t.stop(); }); throw abortError(); }
|
|
streams.add(stream);
|
|
return stream;
|
|
});
|
|
};
|
|
}
|
|
if (window.MediaRecorder) {
|
|
var startRecording = MediaRecorder.prototype.start;
|
|
MediaRecorder.prototype.start = function() {
|
|
if (!current()) throw abortError();
|
|
recorders.add(this);
|
|
var recorder = this;
|
|
this.addEventListener('stop', function() { recorders.delete(recorder); streams.delete(recorder.stream); }, { once: true });
|
|
['stop', 'dataavailable'].forEach(function(type) {
|
|
recorder.addEventListener(type, function(e) {
|
|
if (!current()) e.stopImmediatePropagation();
|
|
}, { capture: true });
|
|
});
|
|
return startRecording.apply(this, arguments);
|
|
};
|
|
}
|
|
[window.SpeechRecognition, window.webkitSpeechRecognition].filter(function(value, i, all) {
|
|
return value && all.indexOf(value) === i;
|
|
}).forEach(function(Recognition) {
|
|
var start = Recognition.prototype.start;
|
|
Recognition.prototype.start = function() {
|
|
if (!current()) throw abortError();
|
|
recognizers.add(this);
|
|
return start.apply(this, arguments);
|
|
};
|
|
});
|
|
window.addEventListener('account-boundary', function() {
|
|
recognizers.forEach(function(rec) {
|
|
rec.onresult = rec.onend = rec.onerror = null;
|
|
try { rec.abort(); } catch (e) {}
|
|
});
|
|
recorders.forEach(function(rec) {
|
|
rec.ondataavailable = rec.onstop = rec.onerror = null;
|
|
try { if (rec.state !== 'inactive') rec.stop(); } catch (e) {}
|
|
});
|
|
streams.forEach(function(stream) { stream.getTracks().forEach(function(track) { track.stop(); }); });
|
|
recorders.clear(); streams.clear(); recognizers.clear();
|
|
if (window.nativeStopRecordingService) window.nativeStopRecordingService();
|
|
if (window.nativeKeepAwake) window.nativeKeepAwake(false);
|
|
});
|
|
})();
|