Skip to content
10 changes: 10 additions & 0 deletions enferno/static/js/common/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,10 @@ const api = {
//global axios response interceptor - handles standardized API responses and global error handling
axios.interceptors.response.use(
function (response) {
if (!hasSilentPollHeader(response?.config?.headers)) {
document.dispatchEvent(new CustomEvent('session-refreshed'));
}

const shouldFlatten =
isPlainObject(response?.data?.data) &&
!response?.config?.skipFlattening;
Expand Down Expand Up @@ -345,6 +349,12 @@ axios.interceptors.response.use(
},
);

function hasSilentPollHeader(headers) {
if (!headers) return false;
if (typeof headers.get === 'function') return Boolean(headers.get('X-Silent-Poll'));
return Boolean(headers['X-Silent-Poll'] || headers['x-silent-poll']);
}

function isPlainObject(val) {
return val !== null && typeof val === 'object' && !Array.isArray(val);
}
Expand Down
2 changes: 1 addition & 1 deletion enferno/static/js/mixins/global-mixin.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
const globalMixin = {
mixins: [reauthMixin, notificationMixin],
mixins: [reauthMixin, notificationMixin, sessionLifecycleMixin],
components: {
'ConfirmDialog': ConfirmDialog,
'Toast': Toast,
Expand Down
23 changes: 23 additions & 0 deletions enferno/static/js/mixins/reauth-mixin.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
const SESSION_RESTORED_STORAGE_KEY = 'bayanat:session-restored';

const reauthMixin = {
data: () => ({
isSignInDialogLoading: false,
Expand All @@ -16,11 +18,22 @@ const reauthMixin = {
}),
created () {
document.addEventListener('authentication-required', this.showLoginDialog);
window.addEventListener('storage', this.onSessionRestoredElsewhere);
},
beforeUnmount() {
document.removeEventListener('authentication-required', this.showLoginDialog);
window.removeEventListener('storage', this.onSessionRestoredElsewhere);
},
methods: {
onSessionRestoredElsewhere(event) {
// Split-screen tabs never fire visibilitychange on each other, so a
// sign-in in one tab needs an explicit cross-tab signal to close the
// others' dialogs instead of waiting on a focus change that won't come.
if (event.key !== SESSION_RESTORED_STORAGE_KEY || !event.newValue) return;
if (!(this.isSignInDialogVisible || this.isReauthDialogVisible)) return;

this.resetState();
},
async onVisibilityChange() {
if (document.visibilityState !== 'visible') return; // only run when tab becomes active

Expand Down Expand Up @@ -183,8 +196,18 @@ const reauthMixin = {
}

this.showSnack('Authentication successful');
this.broadcastSessionRestored();
this.resetState();
},
broadcastSessionRestored() {
try {
// Value must change on every write so sibling tabs' storage listeners
// fire even if a previous signal was never cleared.
localStorage.setItem(SESSION_RESTORED_STORAGE_KEY, String(Date.now()));
} catch (error) {
// Storage unavailable; other tabs simply won't self-close their dialog.
}
},
async select2FAMethod() {
try {
if (this.isSignInDialogLoading) return;
Expand Down
221 changes: 221 additions & 0 deletions enferno/static/js/mixins/session-lifecycle-mixin.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
const SESSION_CADENCE_MAX_MS = 120000;
const SESSION_CADENCE_MIN_MS = 15000;
const SESSION_WARNING_WINDOW_MAX_MS = 60000;
const SESSION_COUNTDOWN_TICK_MS = 1000;
const SESSION_KEEPALIVE_LOCK_TTL_MS = 10000;
const SESSION_REFRESH_STORAGE_KEY = 'bayanat:last-session-refresh';
const SESSION_KEEPALIVE_LOCK_STORAGE_KEY = 'bayanat:session-keepalive-lock';

const sessionLifecycleMixin = {
data: () => ({
sessionWarningVisible: false,
sessionWarningRemainingSeconds: 0,
sessionStaySignedInLoading: false,
sessionLifecycleIntervalId: null,
sessionCountdownIntervalId: null,
sessionInteractedSinceRefresh: false,
sessionLastInteractionAt: 0,
sessionExpiryReported: false,
sessionKeepaliveInFlight: false,
sessionTabId: `${Date.now()}-${Math.random()}`,
}),
computed: {
sessionLifetimeMs() {
return Number(window.__SESSION_LIFETIME__ || 0) * 1000;
},
sessionRefreshCadenceMs() {
if (!this.sessionLifetimeMs) return 0;
return Math.min(
SESSION_CADENCE_MAX_MS,
Math.max(SESSION_CADENCE_MIN_MS, Math.floor(this.sessionLifetimeMs / 3))
);
},
sessionWarningWindowMs() {
if (!this.sessionLifetimeMs) return 0;
return Math.min(SESSION_WARNING_WINDOW_MAX_MS, this.sessionLifetimeMs);
},
sessionAuthPending() {
return Boolean(this.isSignInDialogVisible || this.isReauthDialogVisible || this.isSignInDialogLoading);
},
sessionWarningChecking() {
return Boolean(this.sessionKeepaliveInFlight || this.sessionStaySignedInLoading);
},
},
mounted() {
if (!window.__username__ || !this.sessionLifetimeMs) return;

this.recordSessionRefresh();
this.addSessionActivityListeners();
document.addEventListener('session-refreshed', this.recordSessionRefresh);
document.addEventListener('authentication-required', this.pauseSessionLifecycle);
document.addEventListener('visibilitychange', this.handleSessionVisibilityChange);

this.sessionLifecycleIntervalId = setInterval(
this.checkSessionLifecycle,
Math.min(SESSION_CADENCE_MIN_MS, this.sessionRefreshCadenceMs)
);
this.sessionCountdownIntervalId = setInterval(this.updateSessionWarning, SESSION_COUNTDOWN_TICK_MS);
},
beforeUnmount() {
this.removeSessionActivityListeners();
document.removeEventListener('session-refreshed', this.recordSessionRefresh);
document.removeEventListener('authentication-required', this.pauseSessionLifecycle);
document.removeEventListener('visibilitychange', this.handleSessionVisibilityChange);
clearInterval(this.sessionLifecycleIntervalId);
clearInterval(this.sessionCountdownIntervalId);
},
methods: {
addSessionActivityListeners() {
['input', 'keydown', 'pointerdown', 'wheel', 'touchmove'].forEach(eventName => {
window.addEventListener(eventName, this.markSessionInteraction, { passive: true });
});
},
removeSessionActivityListeners() {
['input', 'keydown', 'pointerdown', 'wheel', 'touchmove'].forEach(eventName => {
window.removeEventListener(eventName, this.markSessionInteraction);
});
},
markSessionInteraction() {
if (this.sessionAuthPending || this.sessionWarningVisible) return;
this.sessionInteractedSinceRefresh = true;
this.sessionLastInteractionAt = Date.now();
},
pauseSessionLifecycle() {
this.sessionWarningVisible = false;
this.sessionInteractedSinceRefresh = false;
this.sessionExpiryReported = false;
},
handleSessionVisibilityChange() {
if (document.visibilityState !== 'visible') {
this.sessionWarningVisible = false;
return;
}

this.updateSessionWarning();
this.checkSessionLifecycle();
},
recordSessionRefresh() {
this.setSessionStorageValue(SESSION_REFRESH_STORAGE_KEY, String(Date.now()));
this.sessionInteractedSinceRefresh = false;
this.sessionWarningVisible = false;
this.sessionExpiryReported = false;
},
lastSessionRefreshAt() {
return Number(this.getSessionStorageValue(SESSION_REFRESH_STORAGE_KEY) || Date.now());
},
sessionKeepaliveLocked() {
const now = Date.now();
const lock = this.getSessionStorageValue(SESSION_KEEPALIVE_LOCK_STORAGE_KEY) || '';
const lockedUntil = Number(lock.split(':')[0] || 0);
if (lockedUntil > now) return true;

const nextLock = `${now + SESSION_KEEPALIVE_LOCK_TTL_MS}:${this.sessionTabId}`;
this.setSessionStorageValue(SESSION_KEEPALIVE_LOCK_STORAGE_KEY, nextLock);
return this.getSessionStorageValue(SESSION_KEEPALIVE_LOCK_STORAGE_KEY) !== nextLock;
},
releaseSessionKeepaliveLock() {
const lock = this.getSessionStorageValue(SESSION_KEEPALIVE_LOCK_STORAGE_KEY) || '';
if (!lock.endsWith(`:${this.sessionTabId}`)) return;
this.removeSessionStorageValue(SESSION_KEEPALIVE_LOCK_STORAGE_KEY);
},
getSessionStorageValue(key) {
try {
return localStorage.getItem(key);
} catch (error) {
return null;
}
},
setSessionStorageValue(key, value) {
try {
localStorage.setItem(key, value);
} catch (error) {
return null;
}
},
removeSessionStorageValue(key) {
try {
localStorage.removeItem(key);
} catch (error) {
return null;
}
},
async checkSessionLifecycle() {
if (document.visibilityState !== 'visible') return;

if (this.sessionAuthPending) {
this.pauseSessionLifecycle();
return;
}

this.updateSessionWarning();
if (this.sessionWarningVisible) return;

const elapsed = Date.now() - this.lastSessionRefreshAt();
if (!this.sessionInteractedSinceRefresh) return;
if (this.sessionLastInteractionAt <= this.lastSessionRefreshAt()) {
this.sessionInteractedSinceRefresh = false;
return;
}

if (elapsed < this.sessionRefreshCadenceMs) return;

if (this.sessionKeepaliveLocked()) return;

try {
this.sessionKeepaliveInFlight = true;
await axios.get('/admin/api/session-check');
} catch (error) {
// Any failure (401, network, timeout) is a no-op here; a real
// expiry is surfaced separately via the global 401 interceptor.
} finally {
this.sessionKeepaliveInFlight = false;
this.releaseSessionKeepaliveLock();
}
},
updateSessionWarning() {
if (this.sessionAuthPending || document.visibilityState !== 'visible') {
this.sessionWarningVisible = false;
return;
}

const remainingMs = this.sessionLifetimeMs - (Date.now() - this.lastSessionRefreshAt());
this.sessionWarningRemainingSeconds = Math.max(0, Math.ceil(remainingMs / 1000));

if (remainingMs <= 0) {
// A keepalive or "Stay signed in" call already in flight is about
// to prove the session alive (or genuinely dead, in which case its
// own 401 reports expiry through the interceptor). Declaring
// expiry here too, from stale timing read mid-request, would be a
// false positive on a session that's actually fine.
if (this.sessionWarningChecking) {
this.sessionWarningVisible = true;
return;
}

// Hand off to the sign-in dialog ourselves (once) rather than
// leaving a page that looks fine over a session that's already dead.
this.sessionWarningVisible = false;
if (!this.sessionExpiryReported) {
this.sessionExpiryReported = true;
document.dispatchEvent(new CustomEvent('authentication-required'));
}
return;
}

this.sessionWarningVisible = remainingMs <= this.sessionWarningWindowMs;
},
async staySignedIn() {
if (this.sessionWarningChecking) return;

try {
this.sessionStaySignedInLoading = true;
await axios.get('/admin/api/session-check');
} catch (error) {
// Any failure (401, network, timeout) is a no-op here; a real
// expiry is surfaced separately via the global 401 interceptor.
} finally {
this.sessionStaySignedInLoading = false;
}
},
},
};
31 changes: 31 additions & 0 deletions enferno/templates/layout.html
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,34 @@

{% include 'login_dialog.html' %}
{% include 'reauth_dialog.html' %}
<v-dialog v-model="sessionWarningVisible" max-width="420" persistent>
<v-card prepend-icon="mdi-clock-alert-outline" title="{{ _('Session Expiring Soon') }}" subtitle="{{ _('Your session is about to expire.') }}">
<v-card-text class="text-body-2">
{{ _('Stay signed in to continue working.') }}
<div v-if="sessionWarningChecking" class="mt-2 text-caption text-medium-emphasis">
{{ _('Checking your session...') }}
</div>
<div v-else class="mt-2 text-caption text-medium-emphasis">
{{ _('Time remaining:') }} ${sessionWarningRemainingSeconds}s
</div>
</v-card-text>

<v-divider></v-divider>

<v-card-actions>
<v-spacer></v-spacer>
<v-btn
color="primary"
variant="flat"
:disabled="sessionWarningChecking"
:loading="sessionWarningChecking"
@click="staySignedIn"
>
{{ _('Stay signed in') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-app>

<confirm-dialog ref="confirmDialog"></confirm-dialog>
Expand All @@ -93,6 +121,7 @@
data-maps-api-endpoint="{{ config.MAPS_API_ENDPOINT }}"
data-ocr-provider="{{ config.OCR_PROVIDER }}"
data-app-version="{{ config.VERSION }}"
data-session-lifetime="{{ config.PERMANENT_SESSION_LIFETIME }}"
></div>

<script nonce="{{ csp_nonce() }}">
Expand All @@ -106,6 +135,7 @@
window.__MAPS_API_ENDPOINT__ = el.dataset.mapsApiEndpoint;
window.__OCR_PROVIDER__ = el.dataset.ocrProvider;
window.__APP_VERSION__ = el.dataset.appVersion;
window.__SESSION_LIFETIME__ = Number(el.dataset.sessionLifetime || 0);
</script>


Expand Down Expand Up @@ -142,6 +172,7 @@
<script src="/static/js/common/config.js?v={{ config.VERSION }}"></script>
<script src="/static/js/mixins/reauth-mixin.js?v={{ config.VERSION }}"></script>
<script src="/static/js/mixins/notification-mixin.js?v={{ config.VERSION }}"></script>
<script src="/static/js/mixins/session-lifecycle-mixin.js?v={{ config.VERSION }}"></script>
<script src="/static/js/mixins/global-mixin.js?v={{ config.VERSION }}"></script>

{% block js %}{% endblock %}
Expand Down
Loading