Compare commits

...
1 Commits
Author SHA1 Message Date
james.maesandClaude Fable 5.2 d9f2baa079 feat(auth): automatic logoff after user inactivity (UI API 1.12.0)
maven / build (push) Successful in 4m26s
build / maven (push) Successful in 4m26s
When the server publishes userInactivityTimeoutSeconds, the app states the age
of the last user action on every request (X-QQQ-User-Activity-Age), since its
own polling is not the user. It warns a minute before the timeout, signs out
when it runs out, shares activity between the tabs of one browser, and renews
the session for an active user whose page sent nothing for four minutes. The
login page says when the sign-out was automatic. Without the server value
nothing changes and no header is sent.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-10 12:34:44 -06:00
13 changed files with 649 additions and 7 deletions

No files matched your search

+12
View File
@@ -118,6 +118,18 @@ describe('LoginPage', () => {
expect(screen.getByLabelText('Username')).toBeInTheDocument()
})
it('says so when the sign-out was the automatic logoff', async () => {
window.sessionStorage.setItem('qqq.signedOutForInactivity', '1')
try {
withAuth({ name: 'mock', type: 'MOCK' }, { isSignedOut: true })
render(<LoginPage />)
expect(await screen.findByRole('heading', { name: 'You were signed out' })).toBeInTheDocument()
expect(screen.getByText('You were signed out after a period of inactivity. Sign in again to continue.')).toBeInTheDocument()
} finally {
window.sessionStorage.removeItem('qqq.signedOutForInactivity')
}
})
it('has no accessibility violations', async () => {
withAuth(tableBased)
const { container } = render(<LoginPage />)
+17 -4
View File
@@ -37,6 +37,7 @@ import { useRouter, useSearchParams } from 'next/navigation'
import type { QLoginBranding } from '@/types'
import { useAuth } from '@/lib/auth/use-auth'
import { INACTIVITY_SIGN_OUT_FLAG } from '@/lib/auth/user-activity'
import { recordReauthAttempt, resetReauthAttempts } from '@/lib/auth/auth-storage'
import { safeReturnTo } from '@/lib/auth/return-to'
import { callbackErrorMessage, consumeProviderError } from '@/lib/auth/callback-errors'
@@ -194,6 +195,7 @@ function LoginContent() {
const autoSignInStarted = useRef(false)
const [loopStopped, setLoopStopped] = React.useState(false)
const [providerError, setProviderError] = useState<string | null>(null)
const [forInactivity, setForInactivity] = useState(false)
const returnTo = safeReturnTo(searchParams.get('returnTo'))
const callbackCode = searchParams.get('error')
@@ -207,6 +209,17 @@ function LoginContent() {
setProviderError(consumeProviderError(callbackCode))
}, [callbackCode])
// Automatic logoff says so: the user did not choose to sign out.
useEffect(() => {
try {
setForInactivity(window.sessionStorage.getItem(INACTIVITY_SIGN_OUT_FLAG) === '1')
} catch {
setForInactivity(false)
}
}, [isSignedOut])
const signedOutHeading = forInactivity ? 'You were signed out' : 'You have signed out'
const signedOutMessage = forInactivity ? 'You were signed out after a period of inactivity. Sign in again to continue.' : 'Sign in again to continue.'
// Pre-sign-in branding: accent colors and favicon, and the app name in the title.
useEffect(() => {
applyBrandingTheme(branding)
@@ -241,8 +254,8 @@ function LoginContent() {
return (
<LoginCard branding={branding}>
<PasswordSignIn
heading={isSignedOut ? 'You have signed out' : 'Sign in'}
message={isSignedOut ? 'Sign in again to continue.' : undefined}
heading={isSignedOut ? signedOutHeading : 'Sign in'}
message={isSignedOut ? signedOutMessage : undefined}
error={errorMessage}
onSubmit={signInWithPassword}
/>
@@ -253,8 +266,8 @@ function LoginContent() {
if (!isLoading && !isAuthenticated && isSignedOut) {
return (
<LoginCard branding={branding}>
<h1 className="text-xl font-semibold text-foreground">You have signed out</h1>
<p className="text-sm text-muted-foreground">Sign in again to continue.</p>
<h1 className="text-xl font-semibold text-foreground">{signedOutHeading}</h1>
<p className="text-sm text-muted-foreground">{signedOutMessage}</p>
<button type="button" onClick={signInAgain} className={BUTTON_CLASS} data-qqq-id="button-sign-in">
Sign in
</button>
+3
View File
@@ -28,6 +28,7 @@ import { QueryClientProvider } from '@tanstack/react-query'
import { queryClient } from '@/lib/query-client'
import { AuthProvider } from '@/lib/auth/auth-provider'
import { InactivityLogoff } from '@/components/layout/InactivityLogoff'
import { ThemeProvider } from '@/lib/theme/theme-provider'
import { injectThemeTokens } from '@/lib/theme/tokens'
import { AppToaster } from '@/components/feedback/Toast'
@@ -83,6 +84,8 @@ export default function Providers({ children }: { children: ReactNode }) {
<AuthProvider>
{/* QA capture (qa-capture extension): nothing is loaded unless the server enables it for this user */}
<QaCaptureLoader />
{/* Automatic logoff: nothing is rendered unless the server has a user inactivity timeout */}
<InactivityLogoff />
{children}
</AuthProvider>
</ThemeProvider>
@@ -0,0 +1,164 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, fireEvent, render, screen } from '@testing-library/react'
import type { QAuthenticationMetaData } from '@/types'
import type { AuthContextType } from '@/lib/auth/auth-provider'
const authState = vi.hoisted(() => ({ value: null as unknown as AuthContextType }))
const api = vi.hoisted(() => ({ lastRequestAt: 0, resumeSession: vi.fn(async () => ({})) }))
vi.mock('@/lib/auth/use-auth', () => ({ useAuth: () => authState.value }))
vi.mock('@/lib/api/auth', () => ({ resumeSession: api.resumeSession }))
vi.mock('@/lib/api/client', () => ({ default: { lastRequestAt: () => api.lastRequestAt } }))
import { InactivityLogoff } from './InactivityLogoff'
import { INACTIVITY_SIGN_OUT_FLAG, resetUserActivityForTests, userActivityHeader, userInactivityTimeout } from '@/lib/auth/user-activity'
const T0 = 1_800_000_000_000
/**
* Sets the auth context the component sees.
*
* @param timeout - The server's user inactivity timeout in seconds, or undefined for none.
* @param isAuthenticated - Whether a user is signed in.
* @returns The logout mock.
*/
function withAuth(timeout: number | undefined, isAuthenticated = true) {
const logout = vi.fn(async () => {})
const metadata: QAuthenticationMetaData = { name: 'oauth2', type: 'OAUTH2', values: { clientId: 'c', userInactivityTimeoutSeconds: timeout } }
authState.value = { isAuthenticated, isLoading: false, user: null, authMetadata: metadata, authError: null, isSignedOut: false, logout } as unknown as AuthContextType
return logout
}
/**
* Lets the given time pass, one tick of the component's clock at a time.
*
* @param seconds - Seconds to advance.
*/
function pass(seconds: number) {
act(() => {
vi.advanceTimersByTime(seconds * 1000)
})
}
describe('InactivityLogoff', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(T0)
window.localStorage.clear()
window.sessionStorage.clear()
resetUserActivityForTests()
api.lastRequestAt = T0
api.resumeSession.mockClear()
})
afterEach(() => {
vi.useRealTimers()
resetUserActivityForTests()
})
it('does nothing when the server has no user inactivity timeout or nobody is signed in', () => {
const logout = withAuth(undefined)
const { unmount } = render(<InactivityLogoff />)
pass(3600)
expect(logout).not.toHaveBeenCalled()
expect(userInactivityTimeout()).toBeNull()
expect(userActivityHeader()).toEqual({})
unmount()
const signedOutLogout = withAuth(900, false)
render(<InactivityLogoff />)
pass(3600)
expect(signedOutLogout).not.toHaveBeenCalled()
expect(screen.queryByText(/signed out in/)).toBeNull()
})
it('warns a minute before and signs the user out when the timeout runs out', () => {
const logout = withAuth(900)
render(<InactivityLogoff />)
expect(userInactivityTimeout()).toBe(900)
pass(839)
expect(screen.queryByText(/signed out in/)).toBeNull()
pass(2)
expect(screen.getByText(/You will be signed out in 59 seconds because of inactivity/)).toBeInTheDocument()
expect(logout).not.toHaveBeenCalled()
pass(59)
expect(logout).toHaveBeenCalledTimes(1)
expect(window.sessionStorage.getItem(INACTIVITY_SIGN_OUT_FLAG)).toBe('1')
expect(screen.queryByText(/signed out in/)).toBeNull()
// once is enough, however long the sign-out takes
pass(120)
expect(logout).toHaveBeenCalledTimes(1)
})
it('polling does not count: only a user action starts the time again', () => {
const logout = withAuth(900)
render(<InactivityLogoff />)
pass(850)
expect(screen.getByText(/signed out in/)).toBeInTheDocument()
// the page keeps talking to the server; that is not the user
api.lastRequestAt = Date.now()
window.dispatchEvent(new Event('focus'))
pass(10)
expect(screen.getByText(/signed out in/)).toBeInTheDocument()
fireEvent.keyDown(document.body, { key: 'a' })
pass(1)
expect(screen.queryByText(/signed out in/)).toBeNull()
pass(830)
expect(logout).not.toHaveBeenCalled()
pass(70)
expect(logout).toHaveBeenCalledTimes(1)
})
it('"Stay signed in" is a user action', () => {
const logout = withAuth(900)
render(<InactivityLogoff />)
pass(870)
fireEvent.click(screen.getByRole('button', { name: 'Stay signed in' }))
expect(screen.queryByText(/signed out in/)).toBeNull()
pass(800)
expect(logout).not.toHaveBeenCalled()
})
it('tells the server about an active user whose page sends no requests', () => {
withAuth(900)
render(<InactivityLogoff />)
// quiet page, idle user: nothing is sent on the user's behalf
pass(300)
expect(api.resumeSession).not.toHaveBeenCalled()
// the user is here, and the page has sent nothing for four minutes
fireEvent.pointerDown(document.body)
pass(1)
expect(api.resumeSession).toHaveBeenCalledTimes(1)
// the request went out: no more until the page is quiet for that long again
api.lastRequestAt = Date.now()
api.resumeSession.mockClear()
fireEvent.pointerDown(document.body)
pass(60)
expect(api.resumeSession).not.toHaveBeenCalled()
})
})
+132
View File
@@ -0,0 +1,132 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* @file Automatic logoff after user inactivity: the timer, the warning and the sign-out.
*/
'use client'
import { useEffect, useRef, useState } from 'react'
import * as AlertDialog from '@radix-ui/react-alert-dialog'
import apiClient from '@/lib/api/client'
import { resumeSession } from '@/lib/api/auth'
import { useAuth } from '@/lib/auth/use-auth'
import {
INACTIVITY_SIGN_OUT_FLAG,
INACTIVITY_WARNING_SECONDS,
lastUserActivityAt,
recordUserActivity,
setUserInactivityTimeout,
startUserActivityTracking,
userActivityAgeSeconds,
} from '@/lib/auth/user-activity'
/** An active user whose page sent nothing for this long has the session renewed, in milliseconds. */
const KEEPALIVE_AFTER_MS = 4 * 60 * 1000
/**
* Signs the user out when the server's user inactivity timeout runs out, and warns shortly
* before. Renders nothing unless the server has such a timeout and a user is signed in.
*
* The server enforces the same timeout by itself (it ends the session at the next request);
* this component is what the user sees. While the user is active on a page that sends no
* request, it renews the session so the user's activity reaches the server.
*
* @returns The warning dialog, or null.
*/
export function InactivityLogoff() {
const { isAuthenticated, authMetadata, logout } = useAuth()
const timeout = authMetadata?.values?.userInactivityTimeoutSeconds
const active = isAuthenticated && typeof timeout === 'number' && timeout > 0
const [remaining, setRemaining] = useState<number | null>(null)
const signingOut = useRef(false)
const logoutRef = useRef(logout)
useEffect(() => {
logoutRef.current = logout
}, [logout])
useEffect(() => {
setUserInactivityTimeout(active ? timeout : null)
if (!active) return
signingOut.current = false
try {
window.sessionStorage.removeItem(INACTIVITY_SIGN_OUT_FLAG)
} catch {
// nothing to forget
}
const stopTracking = startUserActivityTracking()
/** One look at the clock: sign out, warn, or keep the server informed. */
const tick = () => {
if (signingOut.current) return
const now = Date.now()
const left = timeout - userActivityAgeSeconds(now)
if (left <= 0) {
signingOut.current = true
setRemaining(null)
try {
window.sessionStorage.setItem(INACTIVITY_SIGN_OUT_FLAG, '1')
} catch {
// the login page then shows its ordinary signed-out state
}
void logoutRef.current()
return
}
setRemaining(left <= INACTIVITY_WARNING_SECONDS ? left : null)
const lastRequestAt = apiClient.lastRequestAt()
if (lastUserActivityAt() > lastRequestAt && now - lastRequestAt >= KEEPALIVE_AFTER_MS) {
// any request states the user's activity; errors are the 401 handler's business
resumeSession().catch(() => undefined)
}
}
const timer = window.setInterval(tick, 1000)
return () => {
window.clearInterval(timer)
stopTracking()
}
}, [active, timeout])
if (!active || remaining === null) return null
return (
<AlertDialog.Root open>
<AlertDialog.Portal>
<AlertDialog.Overlay className="fixed inset-0 z-[300] bg-black/40" />
<AlertDialog.Content
data-qqq-id="inactivity-logoff-warning"
className="fixed left-1/2 top-1/2 z-[301] w-[min(28rem,calc(100vw-2rem))] -translate-x-1/2 -translate-y-1/2 rounded-lg border bg-background p-6 shadow-lg"
>
<AlertDialog.Title className="text-lg font-semibold">Are you still there?</AlertDialog.Title>
<AlertDialog.Description className="mt-2 text-sm text-muted-foreground" aria-live="polite">
You will be signed out in {remaining} {remaining === 1 ? 'second' : 'seconds'} because of inactivity.
</AlertDialog.Description>
<div className="mt-4 flex justify-end">
<AlertDialog.Action
data-qqq-id="inactivity-logoff-stay"
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground"
onClick={() => {
recordUserActivity()
setRemaining(null)
}}
>
Stay signed in
</AlertDialog.Action>
</div>
</AlertDialog.Content>
</AlertDialog.Portal>
</AlertDialog.Root>
)
}
+2 -1
View File
@@ -36,6 +36,7 @@ import type {
} from '@/types/assistant'
import { getErrorStatusCode } from '@/lib/utils/error-utils'
import apiClient, { apiUrl, CLIENT_CHANNEL_HEADER } from './client'
import { userActivityHeader } from '@/lib/auth/user-activity'
import { anyAbortSignal } from '@/lib/utils/abort'
import { SseParser, type SseEvent } from './sse-parser'
import { normalizeConversationDetail, normalizeStreamEvent } from './assistant-normalize'
@@ -420,7 +421,7 @@ async function openStream(path: string, init: RequestInit, signal?: AbortSignal)
...init,
credentials: 'include',
cache: 'no-store',
headers: { Accept: 'text/event-stream', ...CLIENT_CHANNEL_HEADER, ...(init.headers as Record<string, string> | undefined) },
headers: { Accept: 'text/event-stream', ...CLIENT_CHANNEL_HEADER, ...userActivityHeader(), ...(init.headers as Record<string, string> | undefined) },
signal: signal ? anyAbortSignal([signal, apiClient.sessionSignal()]) : apiClient.sessionSignal(),
})
if (!response.ok) {
+28
View File
@@ -40,6 +40,34 @@ describe('APIClient', () => {
expect(apiClient.getInstance().defaults.headers['X-QQQ-UserTimezone']).toBe(zone)
})
it('states the age of the last user action on every request, only when the server has a user inactivity timeout', async () => {
const { default: apiClient } = await import('./client')
const activity = await import('@/lib/auth/user-activity')
const instance = apiClient.getInstance()
const originalAdapter = instance.defaults.adapter
const stated: Array<unknown> = []
instance.defaults.adapter = async (config) => {
stated.push(config.headers.get(activity.USER_ACTIVITY_AGE_HEADER))
return { data: {}, status: 200, statusText: 'OK', headers: {}, config }
}
try {
activity.resetUserActivityForTests()
const before = Date.now()
await apiClient.get('/metaData')
expect(stated).toEqual([undefined])
expect(apiClient.lastRequestAt()).toBeGreaterThanOrEqual(before)
activity.setUserInactivityTimeout(900)
activity.recordUserActivity(Date.now() - 42_000)
await apiClient.get('/metaData')
await apiClient.post('/manageSession', {})
expect(stated.slice(1)).toEqual(['42', '42'])
} finally {
instance.defaults.adapter = originalAdapter
activity.resetUserActivityForTests()
}
})
it('should expose setUnauthorizedCallback', async () => {
const { default: apiClient } = await import('./client')
expect(typeof apiClient.setUnauthorizedCallback).toBe('function')
+19 -1
View File
@@ -18,6 +18,7 @@
* @file API Client — Singleton Axios instance with global 401 interceptor for all QQQ API calls.
*/
import { userActivityHeader } from '@/lib/auth/user-activity'
import { anyAbortSignal } from '@/lib/utils/abort'
import { getErrorMessage } from '@/lib/utils/error-utils'
@@ -92,6 +93,9 @@ class APIClient {
/** Aborts the current session generation's requests. */
private sessionAbort = new AbortController()
/** When this client last sent a request (epoch milliseconds); 0 before the first one. */
private requestSentAt = 0
/**
* Creates the Axios instance and registers the global 401 response interceptor.
*/
@@ -107,9 +111,14 @@ class APIClient {
},
})
// Every request belongs to the session generation it is sent in.
// Every request belongs to the session generation it is sent in, and states how long ago
// the user last did something when the server has a user inactivity timeout: this app
// polls, and a request alone must not keep a session alive.
this.client.interceptors.request.use((config: EpochConfig) => {
config.qqqSessionEpoch = this.sessionEpoch
const now = Date.now()
this.requestSentAt = now
for (const [name, value] of Object.entries(userActivityHeader(now))) config.headers.set(name, value)
const sessionSignal = this.sessionAbort.signal
config.signal = config.signal ? anyAbortSignal([config.signal as AbortSignal, sessionSignal]) : sessionSignal
return config
@@ -170,6 +179,15 @@ class APIClient {
this.sessionAbort = new AbortController()
}
/**
* When this client last sent a request, so an active user on a quiet page can be noticed.
*
* @returns Epoch milliseconds; 0 before the first request.
*/
lastRequestAt(): number {
return this.requestSentAt
}
/**
* The current session generation's abort signal, for requests made with `fetch` (streams).
*
+2 -1
View File
@@ -63,6 +63,7 @@ import type {
import { getErrorStatusCode } from '@/lib/utils/error-utils'
import { internalAppHref } from '@/lib/utils/internal-app-href'
import apiClient from './client'
import { userActivityHeader } from '@/lib/auth/user-activity'
const BASE = '/qbit/collaboration'
const TARGET_TYPES: CollaborationTargetType[] = [
@@ -685,7 +686,7 @@ export function beaconPresence(input: PresenceRequest) {
method: 'DELETE',
credentials: 'include',
keepalive: true,
headers: { 'Content-Type': 'application/json', 'X-QQQ-Client': 'next-ui' },
headers: { 'Content-Type': 'application/json', 'X-QQQ-Client': 'next-ui', ...userActivityHeader() },
body: JSON.stringify({
connectionId: input.connectionId,
table: input.table,
+2
View File
@@ -16,6 +16,7 @@
/** @file Authenticated realtime transport; shares the existing incremental SSE parser. */
import apiClient, { CLIENT_CHANNEL_HEADER, userTimeZoneHeader } from './client'
import { userActivityHeader } from '@/lib/auth/user-activity'
import { anyAbortSignal } from '@/lib/utils/abort'
import { SseParser, type SseEvent } from './sse-parser'
@@ -40,6 +41,7 @@ export async function readRealtimeStream(
Accept: 'text/event-stream',
...CLIENT_CHANNEL_HEADER,
...userTimeZoneHeader(),
...userActivityHeader(),
...(lastEventId ? { 'Last-Event-ID': lastEventId } : {}),
},
}
+112
View File
@@ -0,0 +1,112 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import {
USER_ACTIVITY_AGE_HEADER,
USER_ACTIVITY_STORAGE_KEY,
lastUserActivityAt,
recordUserActivity,
resetUserActivityForTests,
setUserInactivityTimeout,
startUserActivityTracking,
userActivityAgeSeconds,
userActivityHeader,
userInactivityTimeout,
} from './user-activity'
const T0 = 1_800_000_000_000
describe('user activity (automatic logoff)', () => {
beforeEach(() => {
window.localStorage.clear()
resetUserActivityForTests()
})
afterEach(() => {
resetUserActivityForTests()
})
it('states nothing unless the server has a user inactivity timeout', () => {
recordUserActivity(T0)
expect(userInactivityTimeout()).toBeNull()
expect(userActivityHeader(T0 + 5000)).toEqual({})
for (const off of [null, undefined, 0, -1, Number.NaN, Number.POSITIVE_INFINITY]) {
setUserInactivityTimeout(off)
expect(userInactivityTimeout()).toBeNull()
}
setUserInactivityTimeout(900.9)
expect(userInactivityTimeout()).toBe(900)
expect(userActivityHeader(T0 + 5000)).toEqual({ [USER_ACTIVITY_AGE_HEADER]: '5' })
expect(USER_ACTIVITY_AGE_HEADER).toBe('X-QQQ-User-Activity-Age')
})
it('states the age of the last user action in whole seconds, never negative', () => {
recordUserActivity(T0)
expect(userActivityAgeSeconds(T0)).toBe(0)
expect(userActivityAgeSeconds(T0 + 999)).toBe(0)
expect(userActivityAgeSeconds(T0 + 61_500)).toBe(61)
expect(userActivityAgeSeconds(T0 - 10_000)).toBe(0)
// an earlier action never moves the time backward
recordUserActivity(T0 - 60_000)
expect(lastUserActivityAt()).toBe(T0)
})
it('shares the last user action with the other tabs of the browser, at most once a second', () => {
recordUserActivity(T0)
expect(window.localStorage.getItem(USER_ACTIVITY_STORAGE_KEY)).toBe(String(T0))
recordUserActivity(T0 + 400)
expect(window.localStorage.getItem(USER_ACTIVITY_STORAGE_KEY)).toBe(String(T0))
expect(lastUserActivityAt()).toBe(T0 + 400)
recordUserActivity(T0 + 1200)
expect(window.localStorage.getItem(USER_ACTIVITY_STORAGE_KEY)).toBe(String(T0 + 1200))
// another tab was used later: this tab sees it
window.localStorage.setItem(USER_ACTIVITY_STORAGE_KEY, String(T0 + 30_000))
expect(lastUserActivityAt()).toBe(T0 + 30_000)
expect(userActivityAgeSeconds(T0 + 40_000)).toBe(10)
// nonsense in storage is ignored
window.localStorage.setItem(USER_ACTIVITY_STORAGE_KEY, 'soon')
expect(lastUserActivityAt()).toBe(T0 + 1200)
})
it('counts keys, pointers, touches and the wheel, and nothing else, as user actions', () => {
const stop = startUserActivityTracking()
const loaded = lastUserActivityAt()
expect(loaded).toBeGreaterThan(0)
resetUserActivityForTests()
window.localStorage.clear()
for (const type of ['focus', 'visibilitychange', 'message', 'storage', 'load']) {
window.dispatchEvent(new Event(type))
}
expect(lastUserActivityAt()).toBe(0)
for (const type of ['keydown', 'pointerdown', 'pointermove', 'wheel', 'touchstart']) {
resetUserActivityForTests()
window.localStorage.clear()
document.body.dispatchEvent(new Event(type, { bubbles: true }))
expect(lastUserActivityAt(), type).toBeGreaterThan(0)
}
stop()
resetUserActivityForTests()
window.localStorage.clear()
document.body.dispatchEvent(new Event('keydown', { bubbles: true }))
expect(lastUserActivityAt()).toBe(0)
})
})
+151
View File
@@ -0,0 +1,151 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* @file User activity for automatic logoff (UI API 1.12.0).
*
* A request is not a user action: this app polls. When the server has a user inactivity
* timeout, only what the user does (a key, a pointer, a touch, a wheel) keeps the session
* alive. This module remembers when that last happened, shares it between the tabs of one
* browser (they share the session), and states it to the server on every request as
* `X-QQQ-User-Activity-Age`.
*/
/** The request header that states the age of the last user action, in whole seconds. */
export const USER_ACTIVITY_AGE_HEADER = 'X-QQQ-User-Activity-Age'
/** Where the tabs of one browser share the time of the last user action (epoch milliseconds). */
export const USER_ACTIVITY_STORAGE_KEY = 'qqq.lastUserActivityAt'
/** Session storage flag the login page reads to say the user was signed out for inactivity. */
export const INACTIVITY_SIGN_OUT_FLAG = 'qqq.signedOutForInactivity'
/** How long before the logoff the user is warned, in seconds. */
export const INACTIVITY_WARNING_SECONDS = 60
/** Events that are a user action. Pointer movement counts; it is written at most once a second. */
const USER_ACTION_EVENTS = ['pointerdown', 'pointermove', 'keydown', 'wheel', 'touchstart'] as const
/** Shared storage is written at most this often, in milliseconds. */
const SHARE_INTERVAL_MS = 1000
let timeoutSeconds: number | null = null
let lastActivityAt = 0
let lastSharedAt = 0
/**
* Reads the time of the last user action another tab shared.
*
* @returns Epoch milliseconds, or 0 when nothing usable is stored.
*/
function sharedActivityAt(): number {
try {
const value = Number(window.localStorage.getItem(USER_ACTIVITY_STORAGE_KEY))
return Number.isFinite(value) && value > 0 ? value : 0
} catch {
return 0
}
}
/**
* Sets the server's user inactivity timeout, from the authentication metadata.
*
* @param seconds - The timeout in seconds; anything but a positive number switches it off.
*/
export function setUserInactivityTimeout(seconds: number | null | undefined): void {
timeoutSeconds = typeof seconds === 'number' && Number.isFinite(seconds) && seconds > 0 ? Math.floor(seconds) : null
}
/**
* The server's user inactivity timeout.
*
* @returns Seconds, or null when the server has none.
*/
export function userInactivityTimeout(): number | null {
return timeoutSeconds
}
/**
* Records a user action. Call it from a real user event, a sign-in or a page load, never
* from a timer or a response.
*
* @param now - The time of the action (epoch milliseconds).
*/
export function recordUserActivity(now: number = Date.now()): void {
if (now > lastActivityAt) lastActivityAt = now
if (now - lastSharedAt < SHARE_INTERVAL_MS) return
lastSharedAt = now
try {
window.localStorage.setItem(USER_ACTIVITY_STORAGE_KEY, String(lastActivityAt))
} catch {
// storage is unavailable: this tab still knows its own activity
}
}
/**
* The time of the last user action in any tab of this browser.
*
* @returns Epoch milliseconds; 0 when none was ever seen.
*/
export function lastUserActivityAt(): number {
return Math.max(lastActivityAt, typeof window === 'undefined' ? 0 : sharedActivityAt())
}
/**
* How long ago the user last did something.
*
* @param now - The current time (epoch milliseconds).
* @returns Whole seconds, never negative.
*/
export function userActivityAgeSeconds(now: number = Date.now()): number {
return Math.max(0, Math.floor((now - lastUserActivityAt()) / 1000))
}
/**
* The header that states the user's activity to the server, for every request of a session.
*
* @param now - The current time (epoch milliseconds).
* @returns The header, or nothing when the server has no user inactivity timeout.
*/
export function userActivityHeader(now: number = Date.now()): Record<string, string> {
return timeoutSeconds === null ? {} : { [USER_ACTIVITY_AGE_HEADER]: String(userActivityAgeSeconds(now)) }
}
/**
* Starts listening for user actions in this tab. Loading the page is one.
*
* @returns A function that stops listening.
*/
export function startUserActivityTracking(): () => void {
recordUserActivity()
/** Records the event as a user action. */
const onUserAction = () => {
recordUserActivity()
}
for (const type of USER_ACTION_EVENTS) window.addEventListener(type, onUserAction, { capture: true, passive: true })
return () => {
for (const type of USER_ACTION_EVENTS) window.removeEventListener(type, onUserAction, { capture: true })
}
}
/**
* Forgets everything; for tests.
*/
export function resetUserActivityForTests(): void {
timeoutSeconds = null
lastActivityAt = 0
lastSharedAt = 0
}
+5
View File
@@ -96,6 +96,11 @@ export interface QAuthenticationMetaData {
audience?: string
/** Space-separated scopes to request from an OAUTH2 provider. */
scopes?: string
/**
* Automatic logoff (UI API 1.12.0): seconds without a user action after which the server
* ends the session. Absent when the server has no user inactivity timeout.
*/
userInactivityTimeoutSeconds?: number
}
/**
* Branding that is safe to show before sign-in (QRun-IO/qqq#703): the login page's