Inactivity timeout

Is there a way to set an inactivity timeout on uibakery? If you don’t use the web page for a period of time it will time you out and make you log in again. It’s a security thing just like banks have.

Yes please to this. Ideally configurable, at application level. Really needed for things like PCI-DSS.

Yes please. this is very important for security.

You can do it with Javascript in an action that initiates on app launch:

/**
 * Inactivity logout with custom modal warning 1 minute before logout.
 * HARDENED:
 *  - Modal does NOT auto-hide on activity
 *  - While modal is open, activity does NOT reset timers (forces explicit choice)
 *  - Only buttons close the modal
 */

if ({{app.env === "dev"}}) return "dev";

const timerKey = 'AppInactivityTimer';
const warningKey = 'AppInactivityWarning';
const countdownKey = 'AppInactivityCountdown';
const handlerKey = 'AppResetInactivityHandler';

const TOTAL_TIMEOUT = 5 * 60 * 1000;   // 5 minutes
const WARNING_OFFSET = 1 * 60 * 1000;  // warn 1 minute before logout
const WARNING_DURATION = WARNING_OFFSET;

const MODAL_ID = 'inactivity-warning-modal';
let warningOpen = false;

// ---------- Modal helpers ----------
function ensureModalStyles() {
  if (document.getElementById(`${MODAL_ID}-styles`)) return;

  const style = document.createElement('style');
  style.id = `${MODAL_ID}-styles`;
  style.textContent = `
    #${MODAL_ID}-backdrop{
      position: fixed; inset: 0;
      background: rgba(0,0,0,.45);
      display: none;
      align-items: center;
      justify-content: center;
      z-index: 999999;
    }
    #${MODAL_ID}{
      width: min(520px, calc(100vw - 32px));
      background: #fff;
      border-radius: 14px;
      box-shadow: 0 20px 60px rgba(0,0,0,.25);
      overflow: hidden;
      font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
    }
    #${MODAL_ID} .hdr{
      padding: 16px 18px;
      border-bottom: 1px solid rgba(0,0,0,.08);
      font-weight: 700;
      font-size: 16px;
    }
    #${MODAL_ID} .body{
      padding: 14px 18px 6px 18px;
      font-size: 14px;
      line-height: 1.35;
      color: rgba(0,0,0,.85);
    }
    #${MODAL_ID} .countdown{
      margin-top: 10px;
      font-weight: 700;
      font-size: 14px;
    }
    #${MODAL_ID} .ftr{
      display: flex;
      gap: 10px;
      justify-content: flex-end;
      padding: 14px 18px 16px 18px;
      border-top: 1px solid rgba(0,0,0,.08);
    }
    #${MODAL_ID} button{
      border: 0;
      border-radius: 10px;
      padding: 10px 12px;
      font-weight: 650;
      cursor: pointer;
      font-size: 14px;
    }
    #${MODAL_ID} .btn-secondary{
      background: rgba(0,0,0,.08);
      color: rgba(0,0,0,.85);
    }
    #${MODAL_ID} .btn-primary{
      background: #092E6E;
      color: #fff;
    }
    #${MODAL_ID} .btn-secondary:hover{ filter: brightness(.98); }
    #${MODAL_ID} .btn-primary:hover{ filter: brightness(.95); }

    @media (prefers-color-scheme: dark){
      #${MODAL_ID}{
        background: #141414;
        color: rgba(255,255,255,.92);
      }
      #${MODAL_ID} .hdr,
      #${MODAL_ID} .ftr{ border-color: rgba(255,255,255,.10); }
      #${MODAL_ID} .body{ color: rgba(255,255,255,.86); }
      #${MODAL_ID} .btn-secondary{
        background: rgba(255,255,255,.12);
        color: rgba(255,255,255,.92);
      }
    }
  `;
  document.head.appendChild(style);
}

function ensureModalDom() {
  if (document.getElementById(`${MODAL_ID}-backdrop`)) return;

  const backdrop = document.createElement('div');
  backdrop.id = `${MODAL_ID}-backdrop`;
  backdrop.innerHTML = `
    <div id="${MODAL_ID}" role="dialog" aria-modal="true" aria-labelledby="${MODAL_ID}-title">
      <div class="hdr" id="${MODAL_ID}-title">Still there?</div>
      <div class="body">
        <div>You’ll be logged out in <span class="countdown" id="${MODAL_ID}-countdown">60</span> seconds due to inactivity.</div>
        <div style="margin-top:8px; opacity:.9;">Click <b>Stay Logged In</b> to continue your session.</div>
      </div>
      <div class="ftr">
        <button class="btn-secondary" id="${MODAL_ID}-logout" type="button">Log Out Now</button>
        <button class="btn-primary" id="${MODAL_ID}-stay" type="button">Stay Logged In</button>
      </div>
    </div>
  `;
  document.body.appendChild(backdrop);

  // Disable backdrop click (force explicit choice)
  backdrop.addEventListener('click', (e) => {
    if (e.target === backdrop) {
      // do nothing
    }
  });

  // Harden: disable Escape from closing anything
  document.addEventListener('keydown', (e) => {
    if (!warningOpen) return;
    if (e.key === 'Escape') {
      e.preventDefault();
      e.stopPropagation();
    }
  }, true);
}

function showModal() {
  ensureModalStyles();
  ensureModalDom();

  warningOpen = true;

  const backdrop = document.getElementById(`${MODAL_ID}-backdrop`);
  backdrop.style.display = 'flex';

  // focus primary button
  document.getElementById(`${MODAL_ID}-stay`)?.focus();
}

function hideModal() {
  warningOpen = false;

  const backdrop = document.getElementById(`${MODAL_ID}-backdrop`);
  if (backdrop) backdrop.style.display = 'none';
}

function setCountdown(seconds) {
  const el = document.getElementById(`${MODAL_ID}-countdown`);
  if (el) el.textContent = String(seconds);
}

// ---------- Timer logic ----------
const clearAllTimers = () => {
  if (document[timerKey]) clearTimeout(document[timerKey]);
  if (document[warningKey]) clearTimeout(document[warningKey]);
  if (document[countdownKey]) clearInterval(document[countdownKey]);

  document[timerKey] = null;
  document[warningKey] = null;
  document[countdownKey] = null;
};

const resetTimer = () => {
  // HARDENING: ignore activity while warning modal is open.
  // User must explicitly click Stay Logged In.
  if (warningOpen) return;

  clearAllTimers();

  // Schedule warning
  document[warningKey] = setTimeout(() => {
    showModal();

    // Start countdown from 60 seconds
    let remaining = Math.max(1, Math.round(WARNING_DURATION / 1000));
    setCountdown(remaining);

    const stayBtn = document.getElementById(`${MODAL_ID}-stay`);
    const logoutBtn = document.getElementById(`${MODAL_ID}-logout`);

    if (stayBtn) {
      stayBtn.onclick = () => {
        hideModal();
        resetTimer(); // resets both timers (warningOpen is now false)
      };
    }

    if (logoutBtn) {
      logoutBtn.onclick = () => {
        hideModal();
        user.logout();
      };
    }

    // Countdown interval
    document[countdownKey] = setInterval(() => {
      remaining -= 1;
      if (remaining <= 0) {
        clearInterval(document[countdownKey]);
        document[countdownKey] = null;
        return;
      }
      setCountdown(remaining);
    }, 1000);

  }, TOTAL_TIMEOUT - WARNING_OFFSET);

  // Schedule logout
  document[timerKey] = setTimeout(() => {
    clearAllTimers();
    hideModal();
    console.log('User is inactive for 5 minutes. Logging out...');
    user.logout();
  }, TOTAL_TIMEOUT);
};

// Remove old handlers if they exist
if (document[handlerKey]) {
  document.removeEventListener('mousemove', document[handlerKey]);
  document.removeEventListener('keydown', document[handlerKey]);
  document.removeEventListener('mousedown', document[handlerKey]);
  document.removeEventListener('touchstart', document[handlerKey]);
}

// Assign and register new handler
document[handlerKey] = resetTimer;

// Activity events
document.addEventListener('mousemove', document[handlerKey], { passive: true });
document.addEventListener('keydown', document[handlerKey]);
document.addEventListener('mousedown', document[handlerKey], { passive: true });
document.addEventListener('touchstart', document[handlerKey], { passive: true });

// Start timers immediately
resetTimer();

Shoutout to Matvey for the foundation of this function!

2 Likes