v1.0

IdleLogout

Preview

Basic

Headless — arms an inactivity timer and calls `logoutAction` when it fires.

Preview

Code

ts
import IdleLogout from '@/components/layout/IdleLogout';

src/components/layout/IdleLogout.tsx

AI prompt

text
Build a headless idle-logout (auto-lock) component in React + TypeScript.

Signs the user out after 15 minutes with no activity — for consoles that run on shared workstations. Renders nothing (`return null`).

## Behaviour
- Constants: idle limit 15 min, activity-stamp throttle 5s, check interval 30s, storage key `app.lastActivity`.
- The last-activity timestamp lives in `localStorage`, not a ref, so activity in ANY tab keeps every tab alive — otherwise someone working in one tab gets signed out by an idle one next to it.
- On mount, stamp `Date.now()`. Listen on `window` (passive) for `mousemove`, `mousedown`, `keydown`, `wheel`, `touchstart`; re-stamp at most once per 5s.
- Check every 30s AND on `visibilitychange` when the page becomes visible, so a laptop waking from sleep locks immediately instead of at the next tick. The check reads the stored stamp; if it is missing or younger than the limit, do nothing.
- Once expired, set a `loggingOut` ref (so the check never fires twice) and `await logoutAction()` — a real server action that revokes the session, not just a client redirect. If it throws, fall back to `window.location.href = '/login'`. (In Next.js, a server action that calls `redirect()` throws a redirect that the framework handles.)
- Every storage read/write is in try/catch: if storage is unavailable, never lock rather than lock on every tick off a value that can't be written.
- Clean up all listeners and the interval on unmount; re-run the effect if `logoutAction` changes.

## API
`logoutAction: () => Promise<void>`.

## Usage
Mount once in the authenticated layout: `<IdleLogout logoutAction={logout} />`.

## House style (applies to everything above)
- Stack: React 19 + TypeScript + Tailwind CSS v4, icons from lucide-react. One self-contained file; default-export the component and named-export its types. `'use client'` if it has state, refs or handlers.
- Font Inter; palette indigo on slate. Primary accent indigo-600 (hover indigo-700, dark mode indigo-400). Body text slate-700 / dark slate-200; secondary slate-500 / dark slate-400.
- Dark mode is a `.dark` class on <html> (not prefers-color-scheme). Every colour needs its `dark:` pair.
- Compact admin scale: text-xs (12px) for controls and body, 10–11px for meta, rounded-lg (8px) controls, rounded-2xl (16px) cards.
- Card surface ("panel"): `bg-white/60 dark:bg-slate-800/60 backdrop-blur-xl border border-white/60 dark:border-slate-700/60 rounded-2xl shadow-lg`, on a soft slate gradient page background.
- Floating surfaces (dropdowns, popovers, menus) are OPAQUE: `bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-2xl shadow-lg`, no backdrop blur (it creates a stacking context that traps the popover's z-index). In-flow popovers are z-50; portalled overlays z-200.
- Text inputs and select triggers: `w-full px-3 py-2 text-xs rounded-lg border border-slate-300 dark:border-slate-700 bg-white/80 dark:bg-slate-900/60 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500/40 focus:border-indigo-500`.
- Field labels: 11px semibold slate-600. Section titles: 10px semibold uppercase wide-tracking slate-500.
- Primary button: indigo-600 fill, white 12px semibold text, rounded-lg, px-3 py-2, disabled at 50% opacity. Ghost button: slate-600 text, hover slate-100.
- Popovers close on outside click AND on Escape (listen to both; include the portalled panel's element in the outside-click check).
- Don't nest scroll containers around popovers: an ancestor with overflow hidden/auto clips an absolutely-positioned dropdown. Portal the panel to <body> when it must escape a scroller, and reposition it on scroll and resize.
- Accessible by default: visible focus rings, keyboard support that matches the WAI-ARIA pattern for the widget, `aria-label` on icon-only buttons, `min-w-0` so text truncates instead of overflowing.

Source

tsx
'use client';

/* Origin: bonus-adjustment (96S2), verbatim. */

import { useEffect, useRef } from 'react';

/**
 * Auto-lock: signs the user out after IDLE_LIMIT_MS with no activity. This is a
 * console that moves money, and it runs on shared VPN workstations.
 *
 * The last-activity timestamp lives in localStorage, not a ref, so activity in
 * ANY tab keeps every tab alive — otherwise someone working in one tab gets
 * yanked to /login by an idle one next to it.
 *
 * The check runs on an interval AND on visibilitychange, so a laptop waking from
 * sleep locks immediately instead of waiting for the next tick. It calls the
 * real `logout` server action, which nulls `sessionToken` — this is a genuine
 * revocation, not a client-side redirect.
 */

const IDLE_LIMIT_MS = 15 * 60 * 1000;
const STAMP_THROTTLE_MS = 5_000;
const CHECK_INTERVAL_MS = 30_000;
const STORAGE_KEY = 'ba.lastActivity';

const ACTIVITY_EVENTS = ['mousemove', 'mousedown', 'keydown', 'wheel', 'touchstart'] as const;

export default function IdleLogout({ logoutAction }: { logoutAction: () => Promise<void> }) {
  const loggingOut = useRef(false);

  useEffect(() => {
    const stamp = (t: number) => {
      try {
        localStorage.setItem(STORAGE_KEY, String(t));
      } catch {
        // Private mode / storage disabled: fall back to never locking rather
        // than locking on every tick off a value that can't be written.
      }
    };
    stamp(Date.now());

    let last = Date.now();
    const onActivity = () => {
      const now = Date.now();
      if (now - last >= STAMP_THROTTLE_MS) {
        last = now;
        stamp(now);
      }
    };

    const check = async () => {
      if (loggingOut.current) return;
      let stored: number;
      try {
        stored = Number(localStorage.getItem(STORAGE_KEY) ?? '0');
      } catch {
        return;
      }
      if (!stored || Date.now() - stored < IDLE_LIMIT_MS) return;

      loggingOut.current = true;
      try {
        await logoutAction();
      } catch {
        // logout() redirects via NEXT_REDIRECT, which Next handles; anything
        // else still has to get the user off the page.
        window.location.href = '/login';
      }
    };

    const onVisibility = () => {
      if (document.visibilityState === 'visible') void check();
    };

    ACTIVITY_EVENTS.forEach((e) => window.addEventListener(e, onActivity, { passive: true }));
    document.addEventListener('visibilitychange', onVisibility);
    const timer = setInterval(check, CHECK_INTERVAL_MS);

    return () => {
      ACTIVITY_EVENTS.forEach((e) => window.removeEventListener(e, onActivity));
      document.removeEventListener('visibilitychange', onVisibility);
      clearInterval(timer);
    };
  }, [logoutAction]);

  return null;
}

Props

PropTypeDefaultDescription
logoutAction*() => Promise<void>—