Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9f2baa079
|
No files matched your search
@@ -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 />)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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).
|
||||
*
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 } : {}),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user