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
| Prop | Type | Default | Description |
|---|---|---|---|
logoutAction* | () => Promise<void> | — |