Compare commits

..
Author SHA1 Message Date
james.maes 787a227602 Merge pull request 'fix(preferences): an action that answers with an address opens it; a section says how a connection ended' (#214) from fix/process-link-and-preference-redirect into main
maven / build (push) Successful in 10m17s
build / maven (push) Successful in 10m17s
2026-11-11 12:38:10 +00:00
james.maesandClaude Fable 5.2 30f4f9d586 fix(preferences): an action that answers with an address opens it; a section says how a connection ended
maven / build (push) Successful in 5m2s
build / maven (push) Successful in 5m2s
A preference action that starts an OAuth connection (Connect Slack)
answers with the consent page's address in the action result's message,
because the UI API has no redirect field. The address was the text of a
success toast: long, not clickable and gone in seconds.

- A message that is nothing but one absolute https address is where the
  browser goes, in this tab, and is never shown (continuationUrl,
  leaveTo). A message that only mentions an address is still a message.
  Plain http, credentials in the address and other schemes are not
  followed.
- A flow that returns to /app/preferences?section=<name>&result=<code>
  or &error=<code> gets its outcome shown once above the section's form,
  as the section's label and the code as words ("Slack: connected.",
  "Slack: authorization denied."), as a status or an alert, and taken
  off the address. Only a fixed lowercase code is read, never a sentence.
- Links in an HTML process component use the link color and an
  underline; they looked like the text around them. A link's own inline
  style still wins.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 06:31:38 -06:00
james.maes 24bbe4a038 Merge pull request 'fix(errors): the error banner says what failed and no longer stacks' (#210) from fix/error-banner-names-cause into main
maven / build (push) Successful in 5m7s
build / maven (push) Successful in 5m7s
2026-11-11 00:58:17 +00:00
james.maes 8903f272c4 Merge pull request 'fix(header): the Ask button's activity dot and unread badge sit on its icon' (#213) from fix/ask-indicator-anchor into main
maven / build (push) Successful in 8m37s
build / maven (push) Successful in 8m37s
2026-11-11 00:58:15 +00:00
james.maesandClaude Fable 5.2 2345039911 fix(header): the Ask button's activity dot and unread badge sit on its icon
maven / build (push) Successful in 14m45s
build / maven (push) Successful in 14m44s
The dot shown while a reply is being written was positioned against the
button (absolute bottom-0 right-0). The button's box is its hit area: 10 px
of padding around the icon and, from 1440 px, the "Ask" label, with no
visible edge at rest. The dot therefore landed past the label and under its
baseline, detached from the icon; the unread badge landed over the end of
the label.

Header indicators are now positioned against the icon: HeaderIconAnchor is
a box exactly the size of the icon, with HEADER_BADGE_CLASS (top-right) and
HEADER_STATUS_DOT_CLASS (bottom-right). Ask, the bell and Jobs share it; on
the 40 px icon-only buttons the badge keeps its pixels (2 px inside the
button's top-right corner).

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-10 18:40:45 -06:00
james.maesandClaude Fable 5.2 4347924658 docs(releases): note the 403 fallback and the silent cases of the error banner
maven / build (push) Successful in 4m13s
build / maven (push) Successful in 4m13s
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-10 11:39:00 -06:00
james.maesandClaude Fable 5.2 523f59cf38 fix(errors): the error banner says what failed and no longer stacks
maven / build (push) Successful in 4m33s
build / maven (push) Successful in 4m33s
The global query/mutation error handler showed "Something went wrong" for
every failure without an expected status, and a polling query that kept
failing added one banner per attempt.

- No response: "No response from the server" (or "The server took too long
  to respond" for a timeout), with the request below it as METHOD /path:
  no query string, and ids shown as {id}.
- Unexpected status: the server's own message, else "Request failed (HTTP 409)".
- An error raised by the application shows its own sentence; a runtime error
  shows only its type.
- The banner's sonner id is its text plus the request, so a repeat updates
  the banner on screen. A lost connection has one banner for every request.
- 401, cancelled requests and aborted fetches stay silent. 403 falls back to
  "Permission denied" instead of the axios transport text.
- Each banner logs status, axios code, error name and request to the console,
  never a message, body, header or query string.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-10 11:29:49 -06:00
33 changed files with 872 additions and 774 deletions

No files matched your search

+37 -49
View File
@@ -23,10 +23,12 @@ Axios interceptor
▼
TanStack Query cache (onError)
│ 401 → silently ignored (already handled)
│ 403 → toast "Permission denied" / "You do not have permission…"
│ 403 → toast the server's message, or "Permission denied"
│ 404 → toast "Resource not found"
│ 5xx → toast "Server error — retrying…" / "Server error — please try again"
│ network → toast "Something went wrong"
│ other status → toast the server's message, or "Request failed (HTTP 409)"
│ no response → toast "No response from the server" / "The server took too long to respond"
│ every toast names the request, and a repeat of the same failure updates it
▼
Component (optional per-query onError)
│ Only needed when generic toast is wrong for the specific UX
@@ -66,52 +68,38 @@ The `unauthorizedCallback` is wired to the auth provider's logout/redirect funct
**Location:** `src/lib/query-client.ts` — `handleQueryError` function
```typescript
function handleQueryError(error: unknown, context: 'query' | 'mutation'): void {
const status = getErrorStatusCode(error)
It is registered on both `QueryCache.onError` and `MutationCache.onError`. A query or mutation whose
component renders its own error opts out with `meta: HANDLES_OWN_ERRORS`.
if (status === 401) return // handled by axios interceptor
| Failure | Banner text |
|---|---|
| Cancelled request (an axios cancel or an aborted `fetch`), 401 | none |
| 403 | the server's message (of a JSON body), or `Permission denied` |
| 404 | `Resource not found` |
| 5xx | `Server error — retrying…` (query), `Server error — please try again` (mutation) |
| Any other status (400, 409, 422, …) | the server's message (`userFacingError`, `error` or `message` of a JSON body), or `Request failed (HTTP 409)` |
| No response (connection failed) | `No response from the server` |
| No response (the 30 s timeout) | `The server took too long to respond` |
| An error the application raised (`throw new Error('Invalid audit response')`) | that sentence |
| A runtime error (`TypeError`, a schema error, …) | `The page hit an unexpected error (TypeError)` |
if (status === 403) {
toast.error(
context === 'mutation'
? 'You do not have permission to perform this action'
: 'Permission denied'
)
return
}
Under the text, every banner names what failed: the request as `POST /qqq/v1/table/order/query (HTTP 409)`,
or `While loading records` (the query key's namespace in plain words: `possibleValues` reads
`possible values`) for a query that failed outside a request. The
request is written without its query string, and every path segment that is not a plain word (a record
id, a UUID, an encoded value) is shown as `{id}`. Nothing else of the request or response is shown.
if (status === 404) {
toast.error('Resource not found')
return
}
**One banner per failure.** The banner's sonner `id` is its text plus the request, so a failure that
repeats (a polling query that fails on every attempt) updates the banner already on screen instead of
adding one. A different failure gets its own banner. A lost connection is one cause whichever request
met it, so it has a single banner, which names the request that failed most recently. Without the id
every failed poll added a banner, and sonner pauses a toast's timer while the tab is hidden or the
pointer rests on the stack, so the banners could pile up.
if (status !== undefined && status >= 500) {
toast.error(
context === 'mutation'
? 'Server error — please try again'
: 'Server error — retrying…'
)
return
}
// Network failure or unexpected status
toast.error('Something went wrong')
}
```
This handler is registered on both `QueryCache` and `MutationCache`:
```typescript
export const queryClient = new QueryClient({
queryCache: new QueryCache({
onError: (error) => handleQueryError(error, 'query'),
}),
mutationCache: new MutationCache({
onError: (error) => handleQueryError(error, 'mutation'),
}),
})
```
**Console.** Each banner is also logged once with `console.warn('[API] Request failed:', { context,
status, code, name, request, area })`: the HTTP status, the axios error code (`ERR_NETWORK`,
`ECONNABORTED`, `ERR_BAD_REQUEST`), the error's name, the request as above and the query namespace.
It never logs a message, body, header or query string, because those can carry record data.
**Implication for components:** For most queries and mutations, you do not need `onError` at all. The global handler covers toasting. Add per-query handling only when you need to do something the global handler cannot — such as mapping a 400 error's field messages back to form fields.
@@ -139,7 +127,7 @@ Returns `undefined` for anything else (non-object errors, null, network failures
### 400 — Validation Error
The global handler does NOT toast on 400 because 400s typically carry field-level validation messages that must be mapped back to individual form fields. Handle 400 explicitly wherever you submit a form.
The global handler toasts the server's message for a 400 (or `Request failed (HTTP 400)` when it sent none). 400s typically carry field-level validation messages that belong beside individual form fields, so a form handles 400 explicitly, and sets `meta: HANDLES_OWN_ERRORS` on the mutation when the toast would repeat what the form shows.
```typescript
import { getErrorStatusCode } from '@/lib/utils/error-utils'
@@ -207,7 +195,7 @@ No additional per-component handling is needed for 5xx unless you want a custom
### Network Failure (no response)
Treated as non-4xx by the retry logic — queries retry up to 3 times. The global handler toasts "Something went wrong" after all retries are exhausted. No per-component handling needed for the common case.
Treated as non-4xx by the retry logic — queries retry up to 3 times. After all retries are exhausted the global handler toasts "No response from the server" (or "The server took too long to respond" for the 30 s timeout) with the request that failed. No per-component handling needed for the common case.
---
@@ -274,8 +262,8 @@ export function EditOrderForm({ orderId, schema, defaultValues }: Props) {
onError: (error) => {
if (getErrorStatusCode(error) === 400) {
// Map field errors from the server response body to form fields.
// The global handler does NOT toast on 400, so we must do it here or
// rely on inline field error messages in the form UI.
// The global handler toasts the server's message for a 400; the form
// places each field's message beside it.
const body = (error as AxiosError<{ errors: Record<string, string> }>).response?.data
if (body?.errors) {
for (const [field, message] of Object.entries(body.errors)) {
@@ -305,6 +293,6 @@ export function EditOrderForm({ orderId, schema, defaultValues }: Props) {
| Layer | File | Handles |
|---|---|---|
| Axios interceptor | `src/lib/api/client.ts` | 401 → redirect to login |
| Global query handler | `src/lib/query-client.ts` | 403, 404, 5xx, network → toast |
| Global query handler | `src/lib/query-client.ts` | Every status but 401, no response, page errors → one toast per failure, naming the request |
| Per-component `onError` | Your component | 400 field errors, custom empty states, navigation |
| `getErrorStatusCode` | `src/lib/utils/error-utils.ts` | Normalize `AxiosError` and plain-object errors to HTTP status code |
+8
View File
@@ -16,8 +16,16 @@
## Product fixes
- **A preference action that connects another service takes you there.** A provider that starts an OAuth connection (Connect Slack, for example) answers with the address of the service's consent page in the action result's `message`, since the UI API has no redirect field. That address was shown as the text of a success toast: long, not clickable and gone in seconds. A message that is nothing but one absolute `https:` address is now where the browser goes, in the same tab, and is never shown. A message that only mentions an address is still a message. Plain `http:`, addresses with credentials and every other scheme are not followed.
- **Preferences says how a connection ended.** A flow that left the dashboard returns to `/app/preferences?section=<name>&result=<code>` when it succeeded or `&error=<code>` when it did not. The section now shows the outcome once, above its form, as the section's label and the code as words (`Slack: connected.`, `Slack: authorization denied.`), as a status or an alert, and takes it off the address so a reload does not repeat it. Only a fixed lowercase code is read from the address, never a sentence.
- **Links in process HTML look like links.** An `HTML` process component rendered links in the text color with no underline, so a link the backend put on a screen (a bulk load template, a "Continue to Slack" link) could not be told from the words around it. They now use the link color and an underline; a link's own inline style still wins.
- **The Ask button's activity dot sits on its icon.** While a reply was being written, the dot was placed in the bottom-right corner of the button's hit area, which has no visible edge and, from 1440 px, extends past the "Ask" label, so the dot floated under and to the right of the label. The dot and the unread badge are now positioned against the sparkles icon, at every width and with the panel open or closed. The bell and Jobs badges use the same anchor and keep their place.
- **Logout no longer races an in-flight request.** Logout ends the API client's session generation first: every request still in flight (axios and the realtime and assistant streams) is aborted, and any response of the old generation that still arrives is discarded, so none can refresh the `sessionId` cookie the logout expires or change client auth state (SEC-037, SEC-048).
- **The error banner says what failed, and no longer stacks.** The banner for a failed request used to read "Something went wrong" for every failure without an expected status, and a polling request that kept failing added one banner per attempt. It now reads `No response from the server`, `The server took too long to respond`, the server's own message, or `Request failed (HTTP 409)`, and names the request below it (`POST /qqq/v1/table/order/query`, without the query string, with ids shown as `{id}`). A repeat of the same failure updates the banner on screen; a lost connection shows one banner whichever requests met it. The 403, 404 and 5xx banners keep their wording and gain the request line; a 403 without a server message reads `Permission denied`. A cancelled request, an aborted `fetch` and a 401 show no banner. Each banner logs its status, axios code, error name and request to the console once (`[API] Request failed:`), never a message, body, header or query string.
## Browser testing and infrastructure
None yet.
-12
View File
@@ -118,18 +118,6 @@ 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 />)
+4 -17
View File
@@ -37,7 +37,6 @@ 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'
@@ -195,7 +194,6 @@ 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')
@@ -209,17 +207,6 @@ 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)
@@ -254,8 +241,8 @@ function LoginContent() {
return (
<LoginCard branding={branding}>
<PasswordSignIn
heading={isSignedOut ? signedOutHeading : 'Sign in'}
message={isSignedOut ? signedOutMessage : undefined}
heading={isSignedOut ? 'You have signed out' : 'Sign in'}
message={isSignedOut ? 'Sign in again to continue.' : undefined}
error={errorMessage}
onSubmit={signInWithPassword}
/>
@@ -266,8 +253,8 @@ function LoginContent() {
if (!isLoading && !isAuthenticated && isSignedOut) {
return (
<LoginCard branding={branding}>
<h1 className="text-xl font-semibold text-foreground">{signedOutHeading}</h1>
<p className="text-sm text-muted-foreground">{signedOutMessage}</p>
<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>
<button type="button" onClick={signInAgain} className={BUTTON_CLASS} data-qqq-id="button-sign-in">
Sign in
</button>
-3
View File
@@ -28,7 +28,6 @@ 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'
@@ -84,8 +83,6 @@ 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>
+13 -11
View File
@@ -35,7 +35,7 @@ import { getAlertsInboxSummary } from '@/lib/api/alerts'
import { queryKeys } from '@/lib/query-client'
import type { AlertsInboxSummary } from '@/types/alerts'
import type { QInstance } from '@/types/metadata'
import { HEADER_BUTTON_CLASS } from '@/components/layout/HeaderMenu'
import { HEADER_BADGE_CLASS, HEADER_BUTTON_CLASS, HeaderIconAnchor } from '@/components/layout/HeaderMenu'
const AlertsBellPopover = lazy(() => import('./AlertsBellPopover'))
@@ -132,16 +132,18 @@ export function AlertsBell(_props: { metaData: QInstance | undefined }) {
aria-expanded={open}
data-qqq-id="button-notifications"
>
<Bell className="h-5 w-5" aria-hidden="true" />
{unread > 0 && (
<span
className={`absolute right-0.5 top-0.5 flex h-4 min-w-4 items-center justify-center rounded-full px-1 text-[10px] font-semibold leading-none ${critical ? 'bg-destructive text-white' : 'bg-primary text-primary-foreground'}`}
aria-hidden="true"
data-qqq-id="alerts-unread-badge"
>
{unread > 99 ? '99+' : unread}
</span>
)}
<HeaderIconAnchor>
<Bell className="h-5 w-5" aria-hidden="true" />
{unread > 0 && (
<span
className={`${HEADER_BADGE_CLASS} ${critical ? 'bg-destructive text-white' : 'bg-primary text-primary-foreground'}`}
aria-hidden="true"
data-qqq-id="alerts-unread-badge"
>
{unread > 99 ? '99+' : unread}
</span>
)}
</HeaderIconAnchor>
</button>
<span className="sr-only" aria-live="polite" data-qqq-id="alerts-unread-announcer">{label}</span>
{open && (
@@ -198,7 +198,12 @@ describe('AlertsBell', () => {
expect(bell).toHaveAttribute('aria-expanded', 'false')
// severity is in the name, not only in the badge color
expect(bell).toHaveAccessibleName('Notifications, 2 unread, critical')
expect(screen.getByText('2', { selector: '[data-qqq-id="alerts-unread-badge"]' })).toHaveClass('bg-destructive')
const badge = screen.getByText('2', { selector: '[data-qqq-id="alerts-unread-badge"]' })
expect(badge).toHaveClass('bg-destructive')
// the badge is positioned against the icon (the header's shared anchor), inside the button
expect(badge.parentElement).toHaveAttribute('data-qqq-id', 'header-icon-anchor')
expect(badge.parentElement).toHaveClass('relative')
expect(badge.parentElement?.parentElement).toBe(bell)
expect(document.querySelector('[data-qqq-id="alerts-unread-announcer"]')).toHaveAttribute('aria-live', 'polite')
first.unmount()
@@ -0,0 +1,177 @@
/*
* 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.
*/
// The header's Ask button: its busy dot and unread badge stay on the sparkles icon in every state.
// jsdom has no layout, so these tests assert what decides the layout: which element is each
// indicator's containing block, what gives that element its size, and the indicator's offsets.
import React from 'react'
import { render, screen } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { describe, expect, it, vi } from 'vitest'
import { HEADER_BADGE_CLASS, HEADER_BUTTON_CLASS, HEADER_ICON_ANCHOR_CLASS, HEADER_STATUS_DOT_CLASS } from '@/components/layout/HeaderMenu'
import { assistantKeys, type AssistantController } from './assistant-context'
import { AssistantHeaderButton } from './AssistantHeaderButton'
import { AssistantProvider } from './AssistantProvider'
const ANCHOR = '[data-qqq-id="header-icon-anchor"]'
const BUSY = '[data-qqq-id="assistant-busy"]'
const UNREAD = '[data-qqq-id="assistant-unread-badge"]'
/**
* The class tokens of an element (an SVG element's `className` is not a string).
*
* @param element - The element.
* @returns Its classes.
*/
function classes(element: Element): string[] {
return (element.getAttribute('class') ?? '').split(/\s+/).filter(Boolean)
}
/**
* The containing block of an absolutely positioned element: its nearest positioned ancestor.
*
* @param element - The absolutely positioned element.
* @returns The ancestor its offsets are measured from, or null (the viewport).
*/
function containingBlock(element: Element): Element | null {
for (let parent = element.parentElement; parent; parent = parent.parentElement) {
if (classes(parent).some((name) => ['relative', 'absolute', 'fixed', 'sticky'].includes(name))) return parent
}
return null
}
/**
* The children that give an element its size: every child that is not absolutely positioned.
*
* @param element - The element.
* @returns Its in-flow children.
*/
function inFlowChildren(element: Element): Element[] {
return Array.from(element.children).filter((child) => !classes(child).includes('absolute'))
}
/**
* Renders the button in a given state.
*
* @param state - The state.
* @param state.busy - Whether a reply is being written.
* @param state.unreadCount - Unread replies.
* @param state.open - Whether the panel is open.
* @param state.enabled - Whether the assistant is available.
* @returns The button, or null when it renders nothing.
*/
function renderButton({ busy = false, unreadCount = 0, open = false, enabled = true }: { busy?: boolean; unreadCount?: number; open?: boolean; enabled?: boolean }): HTMLElement | null {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
client.setQueryData(assistantKeys.busy(), busy)
const controller: AssistantController = {
config: null,
enabled,
open,
setOpen: vi.fn(),
toggle: vi.fn(),
activeConversationId: null,
setActiveConversationId: vi.fn(),
unreadCount,
openWith: vi.fn(),
pendingRequest: null,
consumeRequest: vi.fn(),
triggerRef: React.createRef<HTMLButtonElement>(),
}
render(
<QueryClientProvider client={client}>
<AssistantProvider value={controller}>
<header><AssistantHeaderButton /></header>
</AssistantProvider>
</QueryClientProvider>
)
return screen.queryByRole('button', { name: /^Ask the assistant/ })
}
describe('AssistantHeaderButton', () => {
it('renders nothing when the assistant is unavailable', () => {
expect(renderButton({ enabled: false, busy: true, unreadCount: 2 })).toBeNull()
})
it('idle: the icon anchor holds only the icon, and the label sits beside it', () => {
const button = renderButton({})!
const anchor = button.querySelector(ANCHOR)!
expect(anchor.parentElement).toBe(button)
expect(Array.from(anchor.children).map((child) => child.tagName.toLowerCase())).toEqual(['svg'])
expect(button.querySelector(BUSY)).toBeNull()
expect(button.querySelector(UNREAD)).toBeNull()
const label = screen.getByText('Ask')
expect(label.parentElement).toBe(button)
expect(anchor.contains(label)).toBe(false)
expect(button).toHaveAttribute('aria-pressed', 'false')
})
const states = [
{ name: 'a reply being written', busy: true, unreadCount: 0 },
{ name: 'unread replies', busy: false, unreadCount: 3 },
{ name: 'a reply being written and unread replies', busy: true, unreadCount: 12 },
]
const cases = states.flatMap((state) => [{ ...state, open: false, panel: 'closed' }, { ...state, open: true, panel: 'open' }])
it.each(cases)('$name, panel $panel: every indicator is positioned against the icon, never the button', ({ busy, unreadCount, open }) => {
const button = renderButton({ busy, unreadCount, open })!
expect(button).toHaveAttribute('aria-pressed', String(open))
const anchor = button.querySelector(ANCHOR)!
const indicators = Array.from(button.querySelectorAll(`${BUSY}, ${UNREAD}`))
expect(indicators).toHaveLength((busy ? 1 : 0) + (unreadCount > 0 ? 1 : 0))
for (const indicator of indicators) {
expect(classes(indicator)).toContain('absolute')
// the button is positioned too (its focus ring and the Jobs ring need that), so only an
// indicator inside the anchor is measured from the icon
expect(containingBlock(indicator)).toBe(anchor)
expect(containingBlock(indicator)).not.toBe(button)
}
// the anchor is exactly the icon: nothing else in flow, so the label and the button's
// padding cannot move an indicator
expect(classes(anchor)).toContain('relative')
expect(inFlowChildren(anchor).map((child) => child.tagName.toLowerCase())).toEqual(['svg'])
expect(anchor.contains(screen.getByText('Ask'))).toBe(false)
})
it('places the dot on the icon\'s bottom-right corner and the badge over its top-right corner', () => {
const button = renderButton({ busy: true, unreadCount: 3 })!
const dot = screen.getByRole('status', { name: 'Assistant is answering' })
expect(dot).toBe(button.querySelector(BUSY))
expect(classes(dot)).toEqual(expect.arrayContaining(['-bottom-0.5', '-right-0.5', 'h-2', 'w-2', 'rounded-full']))
const badge = button.querySelector(UNREAD)!
expect(badge).toHaveTextContent('3')
expect(classes(badge)).toEqual(expect.arrayContaining(['-right-2', '-top-2', 'h-4', 'min-w-4']))
// the offsets of the button's own corner (the defect: past the label, under its baseline)
for (const indicator of [dot, badge]) {
for (const offset of ['bottom-0', 'right-0', 'right-0.5', 'top-0.5']) expect(classes(indicator)).not.toContain(offset)
}
})
it('caps the badge at 9+ and names the unread count', () => {
const button = renderButton({ unreadCount: 12 })!
expect(button.querySelector(UNREAD)).toHaveTextContent('9+')
expect(button).toHaveAccessibleName('Ask the assistant, 12 unread (Ctrl+J or ⌘J)')
})
it('shares one anchor and one badge geometry with the bell and Jobs', () => {
expect(HEADER_ICON_ANCHOR_CLASS.split(' ')).toEqual(expect.arrayContaining(['relative', 'shrink-0']))
expect(HEADER_BADGE_CLASS.split(' ')).toEqual(expect.arrayContaining(['absolute', '-right-2', '-top-2']))
expect(HEADER_STATUS_DOT_CLASS.split(' ')).toEqual(expect.arrayContaining(['absolute', '-bottom-0.5', '-right-0.5']))
// an 8 px offset from a 20 px icon stays inside the 10 px padding of the 40 px button
expect(HEADER_BUTTON_CLASS.split(' ')).toEqual(expect.arrayContaining(['h-10', 'min-w-10', 'px-2.5']))
})
})
@@ -25,13 +25,14 @@ import React from 'react'
import { useQuery } from '@tanstack/react-query'
import { Sparkles } from 'lucide-react'
import { HEADER_BUTTON_CLASS } from '@/components/layout/HeaderMenu'
import { HEADER_BADGE_CLASS, HEADER_BUTTON_CLASS, HEADER_STATUS_DOT_CLASS, HeaderIconAnchor } from '@/components/layout/HeaderMenu'
import { cn } from '@/lib/utils/cn'
import { assistantKeys, useAssistant } from './assistant-context'
/**
* Toggles the assistant panel ("Ask"); the badge counts unread replies. The sparkles icon alone is
* ambiguous, so from 1440 px it carries the text label "Ask" (its tooltip says so below that).
* Toggles the assistant panel ("Ask"); the badge counts unread replies and the dot shows a reply
* being written. The sparkles icon alone is ambiguous, so from 1440 px it carries the text label
* "Ask" (its tooltip says so below that).
*
* @returns The button, or null when the assistant is unavailable.
*/
@@ -50,24 +51,23 @@ export function AssistantHeaderButton() {
event.currentTarget.focus({ preventScroll: true })
toggle()
}}
className={cn(HEADER_BUTTON_CLASS, 'gap-1.5')}
className={cn(HEADER_BUTTON_CLASS, 'gap-2')}
aria-label={label}
title="Ask the assistant (Ctrl+J or ⌘J)"
aria-pressed={open}
data-qqq-id="button-assistant"
>
<Sparkles className="h-5 w-5" aria-hidden="true" />
{/* the indicators sit on the icon: the button's corner is past the label from 1440 px */}
<HeaderIconAnchor>
<Sparkles className="h-5 w-5" aria-hidden="true" />
{busy && <span className={cn(HEADER_STATUS_DOT_CLASS, 'bg-primary motion-safe:animate-pulse')} role="status" aria-label="Assistant is answering" data-qqq-id="assistant-busy" />}
{unreadCount > 0 && (
<span className={cn(HEADER_BADGE_CLASS, 'bg-primary text-primary-foreground')} aria-hidden="true" data-qqq-id="assistant-unread-badge">
{unreadCount > 9 ? '9+' : unreadCount}
</span>
)}
</HeaderIconAnchor>
<span className="hidden text-sm font-medium min-[1440px]:inline" aria-hidden="true">Ask</span>
{busy && <span className="absolute bottom-0 right-0 h-2 w-2 rounded-full bg-primary motion-safe:animate-pulse" role="status" aria-label="Assistant is answering" data-qqq-id="assistant-busy" />}
{unreadCount > 0 && (
<span
className="absolute right-0.5 top-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-semibold leading-none text-primary-foreground"
aria-hidden="true"
data-qqq-id="assistant-unread-badge"
>
{unreadCount > 9 ? '9+' : unreadCount}
</span>
)}
</button>
)
}
@@ -106,6 +106,12 @@ describe('JobsHeaderButton and JobsPopover', () => {
it('shows an active badge, both lists, keyboard trap and restores focus on Escape', async () => {
renderConnected(<JobsHeaderButton />)
const button = await screen.findByRole('button', { name: 'Jobs, 1 active' })
// the badge is positioned against the icon (the header's shared anchor), inside the button
const badge = button.querySelector('[data-qqq-id="jobs-active-badge"]')
expect(badge).toHaveTextContent('1')
expect(badge?.parentElement).toHaveAttribute('data-qqq-id', 'header-icon-anchor')
expect(badge?.parentElement).toHaveClass('relative')
expect(badge?.parentElement?.parentElement).toBe(button)
fireEvent.click(button)
const dialog = await screen.findByRole('dialog', { name: 'Jobs' })
expect(await screen.findByText('Import')).toBeInTheDocument()
+13 -11
View File
@@ -18,7 +18,7 @@
'use client'
import { lazy, Suspense, useCallback, useEffect, useRef, useState } from 'react'
import { BriefcaseBusiness } from 'lucide-react'
import { HEADER_BUTTON_CLASS } from '@/components/layout/HeaderMenu'
import { HEADER_BADGE_CLASS, HEADER_BUTTON_CLASS, HeaderIconAnchor } from '@/components/layout/HeaderMenu'
import { useJobsInstalled, useMyJobs } from '@/lib/hooks/use-my-jobs'
import './jobs.css'
@@ -76,16 +76,18 @@ export function JobsHeaderButton() {
className="qqq-job-ring absolute inset-1 rounded-full border border-primary"
/>
)}
<BriefcaseBusiness aria-hidden="true" className="h-5 w-5" />
{count > 0 && (
<span
aria-hidden="true"
className="absolute right-0.5 top-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-semibold leading-none text-primary-foreground"
data-qqq-id="jobs-active-badge"
>
{count > 99 ? '99+' : count}
</span>
)}
<HeaderIconAnchor>
<BriefcaseBusiness aria-hidden="true" className="h-5 w-5" />
{count > 0 && (
<span
aria-hidden="true"
className={`${HEADER_BADGE_CLASS} bg-primary text-primary-foreground`}
data-qqq-id="jobs-active-badge"
>
{count > 99 ? '99+' : count}
</span>
)}
</HeaderIconAnchor>
</button>
<span aria-live="polite" className="sr-only" data-qqq-id="jobs-announcer">
{announcement}
+28
View File
@@ -33,6 +33,34 @@ export const HEADER_BUTTON_CLASS = cn(
'hover:bg-accent hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring'
)
/**
* The positioning context of a header button's indicators: a box exactly the size of its icon.
* The button's own box is the hit area (padding, and a text label from 1440 px on Ask) with no
* visible edge at rest, so an indicator placed against it drifts away from the icon.
*/
export const HEADER_ICON_ANCHOR_CLASS = 'relative flex shrink-0'
/**
* A count badge over the icon's top-right corner (unread, active). On a 40 px icon-only button this
* is 2 px inside the button's top-right corner. Add the fill and text colors.
*/
export const HEADER_BADGE_CLASS = 'absolute -right-2 -top-2 flex h-4 min-w-4 items-center justify-center rounded-full px-1 text-[10px] font-semibold leading-none'
/** A status dot on the icon's bottom-right corner (activity). Add the fill color. */
export const HEADER_STATUS_DOT_CLASS = 'absolute -bottom-0.5 -right-0.5 h-2 w-2 rounded-full'
/**
* A header button's icon together with its indicators ({@link HEADER_BADGE_CLASS},
* {@link HEADER_STATUS_DOT_CLASS}), which stay on the icon whatever else the button holds.
*
* @param props - Component properties.
* @param props.children - The icon, then its indicators.
* @returns The anchor.
*/
export function HeaderIconAnchor({ children }: { children: React.ReactNode }) {
return <span className={HEADER_ICON_ANCHOR_CLASS} data-qqq-id="header-icon-anchor">{children}</span>
}
/** A menu row: full width, 44 px tall on touch. */
export const HEADER_MENU_ITEM_CLASS = cn(
'flex w-full items-center gap-3 px-4 py-2.5 text-left text-sm font-medium text-foreground pointer-coarse:min-h-11',
@@ -1,164 +0,0 @@
/*
* 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
@@ -1,132 +0,0 @@
/*
* 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>
)
}
@@ -32,8 +32,15 @@ vi.mock('@/lib/hooks/use-container-width', () => ({
useContainerWidth: () => [undefined, layout.width],
}))
vi.mock('sonner', () => ({ toast: { success: vi.fn() } }))
const navigation = vi.hoisted(() => ({ leaveTo: vi.fn() }))
vi.mock('@/lib/utils/external-navigation', async (original) => ({
...(await original<typeof import('@/lib/utils/external-navigation')>()),
leaveTo: navigation.leaveTo,
}))
beforeEach(() => {
layout.width = 500
navigation.leaveTo.mockClear()
vi.mocked(toast.success).mockClear()
})
const tokens = preferencesFixture.sections.find((section) => section.name === 'access-tokens')!
function renderForm(section: PreferenceSection = structuredClone(tokens)) {
@@ -387,3 +394,36 @@ it('shows each returned secret and restores focus to the returned row after ackn
expect(screen.queryByDisplayValue('recovery-test-only')).not.toBeInTheDocument()
persist.mockRestore()
})
it('sends the browser to an address-only result and never shows the address', async () => {
const authorize =
'https://slack.com/oauth/v2/authorize?client_id=1.2&scope=&user_scope=chat%3Awrite&redirect_uri=https%3A%2F%2Fapp.example.test%2Fqqq%2Fhooks%2Fslack%2Foauth%2Fcallback&state=abc.def'
const section = structuredClone(tokens)
section.actions!.push({ name: 'connect', label: 'Connect service', scope: 'SECTION' })
server.use(
http.post('/qqq/v1/preferences/access-tokens/actions/connect', () =>
HttpResponse.json({ section, message: authorize })
)
)
renderForm(section)
await userEvent.setup().click(screen.getByRole('button', { name: 'Connect service' }))
await waitFor(() => expect(navigation.leaveTo).toHaveBeenCalledWith(authorize))
expect(toast.success).not.toHaveBeenCalled()
expect(screen.queryByText(/slack\.com/)).not.toBeInTheDocument()
})
it('still shows a result that only mentions an address as a message', async () => {
const section = structuredClone(tokens)
section.actions!.push({ name: 'connect', label: 'Connect service', scope: 'SECTION' })
server.use(
http.post('/qqq/v1/preferences/access-tokens/actions/connect', () =>
HttpResponse.json({ section, message: 'Connected. See https://example.test/help for more.' })
)
)
renderForm(section)
await userEvent.setup().click(screen.getByRole('button', { name: 'Connect service' }))
await waitFor(() =>
expect(toast.success).toHaveBeenCalledWith('Connected. See https://example.test/help for more.')
)
expect(navigation.leaveTo).not.toHaveBeenCalled()
})
@@ -32,6 +32,7 @@ import type {
} from '@/types/preferences'
import { runPreferenceAction } from '@/lib/api/preferences'
import { queryKeys } from '@/lib/query-client'
import { continuationUrl, leaveTo } from '@/lib/utils/external-navigation'
import { BUTTON_CLASS, buttonClass } from '@/components/ui/controls'
import { MetadataIcon } from '@/components/layout/MetadataIcon'
import { PreferenceField } from './PreferenceField'
@@ -381,7 +382,11 @@ export function usePreferenceActions({
showingSecrets.current = Boolean(result.secrets?.length)
onSaved(result.section)
setSelected(null)
if (result.message) toast.success(result.message)
// An answer that is only an address (the consent page of a service being connected) is
// where the browser goes next. It is never shown: nobody can use an address in a toast.
const destination = continuationUrl(result.message)
if (destination) leaveTo(destination)
else if (result.message) toast.success(result.message)
setSecrets(result.secrets ?? [])
} catch (error) {
if (mounted.current) setErrors(preferenceMessages(error))
@@ -22,7 +22,7 @@ import { beforeEach, expect, it, vi } from 'vitest'
import { axe } from 'jest-axe'
import { server } from '@/mocks/node'
import { ThemeProvider } from '@/lib/theme/theme-provider'
import { PreferencesPage } from './PreferencesPage'
import { PreferencesPage, sectionOutcome } from './PreferencesPage'
const state = vi.hoisted(() => ({
search: '',
@@ -107,3 +107,42 @@ it('offers retry on errors and keeps local display settings accessible', async (
await userEvent.setup().click(screen.getByRole('button', { name: 'Retry' }))
await waitFor(() => expect(screen.queryByRole('alert')).not.toBeInTheDocument())
})
it('says once that a flow that returned to a section succeeded, and takes the outcome off the address', async () => {
state.search = 'section=regional&result=connected'
renderPage()
await screen.findByRole('heading', { name: 'Regional settings' })
const outcome = screen.getByText('Regional settings: connected.')
expect(outcome).toHaveAttribute('role', 'status')
expect(outcome).toHaveAttribute('data-qqq-id', 'preferences-section-outcome')
expect(state.replace).toHaveBeenCalledWith('/app/preferences?section=regional', { scroll: false })
await userEvent.setup().click(screen.getByRole('button', { name: /Notifications What reaches/ }))
expect(screen.queryByText('Regional settings: connected.')).not.toBeInTheDocument()
})
it('names the reason a flow that returned to a section failed', async () => {
state.search = 'section=regional&error=authorization_denied'
renderPage()
expect(await screen.findByRole('alert')).toHaveTextContent(
'Regional settings: authorization denied.'
)
})
it('reads only fixed outcome codes from the address, never a sentence', () => {
const read = (search: string) => sectionOutcome(new URLSearchParams(search))
expect(read('section=slack&result=connected')).toEqual({
section: 'slack',
failed: false,
words: 'connected',
})
expect(read('section=slack&error=sign_in_required&result=connected')).toEqual({
section: 'slack',
failed: true,
words: 'sign in required',
})
expect(read('section=slack')).toBeNull()
expect(read('result=connected')).toBeNull()
expect(read('section=slack&error=Call+555-0100+now')).toBeNull()
expect(read('section=slack&error=%3Cb%3Ex%3C%2Fb%3E')).toBeNull()
expect(read(`section=slack&error=${'a_'.repeat(20)}a`)).toBeNull()
})
+50 -2
View File
@@ -17,13 +17,14 @@
/** @file Preferences route content, with device display settings and server-owned sections. */
'use client'
import { useEffect } from 'react'
import { useEffect, useState } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import { useQuery } from '@tanstack/react-query'
import { getPreferences } from '@/lib/api/preferences'
import { queryKeys } from '@/lib/query-client'
import { useQContext } from '@/lib/context/q-context'
import { useMediaQuery } from '@/lib/hooks/use-media-query'
import { cn } from '@/lib/utils/cn'
import { BUTTON_CLASS } from '@/components/ui/controls'
import { MetadataIcon } from '@/components/layout/MetadataIcon'
import { DisplayPreferencesPanel } from '@/components/layout/DisplayPreferencesPanel'
@@ -43,6 +44,30 @@ export function PreferencesSkeleton() {
)
}
/** What a flow that left the dashboard reports when it returns to a section. */
export interface SectionOutcome {
section: string
failed: boolean
/** The provider's outcome code as words: `authorization_denied` reads "authorization denied". */
words: string
}
/** Reads the outcome a flow that left the dashboard (a consent page of a connected service)
* puts on the address when it returns: `?section=<name>&result=<code>` when it succeeded,
* `&error=<code>` when it did not. The code is one of the provider's fixed lowercase names
* (`connected`, `authorization_denied`). It is shown as words after the section's own label;
* anything else on the address is ignored, so a link cannot put its own sentence on the page.
* @param params - The page's query string.
* @returns The outcome, or `null` when the address reports none.
*/
export function sectionOutcome(params: URLSearchParams): SectionOutcome | null {
const section = params.get('section')
const error = params.get('error')
const code = error ?? params.get('result')
if (!section || !code || !/^[a-z]+(?:_[a-z]+){0,7}$/.test(code)) return null
return { section, failed: error !== null, words: code.replace(/_/g, ' ') }
}
/** Every registered section and a client-only Display section, selected through the URL.
* @returns Preferences navigation and selected section.
*/
@@ -59,6 +84,13 @@ export function PreferencesPage() {
})
// A client-only key cannot collide with the backend's [a-z] section names.
const requested = params.get('section')
const [outcome, setOutcome] = useState(() => sectionOutcome(params))
const reported = params.has('result') || params.has('error')
// An outcome is reported once: off the address it goes, so a reload does not repeat it.
useEffect(() => {
if (reported && requested)
router.replace(`/app/preferences?section=${encodeURIComponent(requested)}`, { scroll: false })
}, [reported, requested, router])
const selected =
requested === 'display' && !query.data?.sections.some((section) => section.name === 'display')
? '__display'
@@ -73,13 +105,15 @@ export function PreferencesPage() {
...(query.data?.sections ?? []),
]
const section = query.data?.sections.find((section) => section.name === selected)
const navigate = (name: string) =>
const navigate = (name: string) => {
setOutcome(null)
router.replace(
name === '__display'
? '/app/preferences'
: `/app/preferences?section=${encodeURIComponent(name)}`,
{ scroll: false }
)
}
return (
<div className="mx-auto w-full max-w-7xl space-y-6" data-qqq-id="preferences-page">
<h1 className="text-2xl font-semibold text-foreground">Preferences</h1>
@@ -159,6 +193,20 @@ export function PreferencesPage() {
<PreferencesSkeleton />
) : section ? (
<>
{outcome?.section === section.name && (
<p
role={outcome.failed ? 'alert' : 'status'}
className={cn(
'rounded-md border p-3 text-sm',
outcome.failed
? 'border-destructive text-destructive'
: 'border-border bg-muted text-foreground'
)}
data-qqq-id="preferences-section-outcome"
>
{`${section.label}: ${outcome.words}.`}
</p>
)}
<PreferenceSectionForm key={section.name} section={section} />
</>
) : (
+2 -1
View File
@@ -56,9 +56,10 @@ export function HtmlComponent({ index }: HtmlComponentProps) {
const raw = values[`${step.name}.html`]
const html = useMemo(() => (typeof raw === 'string' && raw ? sanitizeProcessHtml(raw) : ''), [raw])
if (!html) return null
// links read as links: without it they inherit the text color and have no underline
return (
<div
className="prose prose-sm max-w-none text-foreground"
className="prose prose-sm max-w-none text-foreground [&_a]:text-link [&_a]:underline"
data-qqq-id={`process-html-${index}`}
dangerouslySetInnerHTML={{ __html: html }}
/>
@@ -205,6 +205,24 @@ describe('ProcessSummaryLines', () => {
})
})
describe('HTML component', () => {
it('shows a link the backend put on the last screen as a link that opens in this tab', () => {
const address = 'https://slack.com/oauth/v2/authorize?client_id=1.2&scope=&user_scope=chat%3Awrite&state=abc.def'
const step: QFrontendStepMetaData = { name: 'result', label: 'Continue to Slack', components: [{ type: 'HTML' }] }
renderStep(step, {
noMoreSteps: true,
'result.html': `<p>Slack will ask you to approve the connection.</p><p><a href="${address.replace(/&/g, '&amp;')}" style="display:inline-block;padding:8px 16px;background:#4A154B;color:#ffffff">Continue to Slack</a></p><p>This link expires in 10 minutes.</p>`,
})
const link = screen.getByRole('link', { name: 'Continue to Slack' })
expect(link).toHaveAttribute('href', address)
expect(link).not.toHaveAttribute('target')
expect(link.getAttribute('style')).toContain('background')
expect(link.closest('[data-qqq-id="process-html-0"]')).toHaveClass('[&_a]:text-link', '[&_a]:underline')
expect(screen.getByText('This link expires in 10 minutes.')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Return' })).toBeInTheDocument()
})
})
describe('process helpers', () => {
it('sanitizes HTML but keeps CSV and text template downloads', () => {
const html = sanitizeProcessHtml('<a href="data:text/csv;base64,QQ==" download="t.csv">t</a><a href="data:text/html,<b>x</b>">h</a><img src="x" onerror="alert(1)"><script>alert(1)</script>')
+1 -2
View File
@@ -36,7 +36,6 @@ 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'
@@ -421,7 +420,7 @@ async function openStream(path: string, init: RequestInit, signal?: AbortSignal)
...init,
credentials: 'include',
cache: 'no-store',
headers: { Accept: 'text/event-stream', ...CLIENT_CHANNEL_HEADER, ...userActivityHeader(), ...(init.headers as Record<string, string> | undefined) },
headers: { Accept: 'text/event-stream', ...CLIENT_CHANNEL_HEADER, ...(init.headers as Record<string, string> | undefined) },
signal: signal ? anyAbortSignal([signal, apiClient.sessionSignal()]) : apiClient.sessionSignal(),
})
if (!response.ok) {
-28
View File
@@ -40,34 +40,6 @@ 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')
+1 -19
View File
@@ -18,7 +18,6 @@
* @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'
@@ -93,9 +92,6 @@ 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.
*/
@@ -111,14 +107,9 @@ class APIClient {
},
})
// 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.
// Every request belongs to the session generation it is sent in.
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
@@ -179,15 +170,6 @@ 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).
*
+1 -2
View File
@@ -63,7 +63,6 @@ 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[] = [
@@ -686,7 +685,7 @@ export function beaconPresence(input: PresenceRequest) {
method: 'DELETE',
credentials: 'include',
keepalive: true,
headers: { 'Content-Type': 'application/json', 'X-QQQ-Client': 'next-ui', ...userActivityHeader() },
headers: { 'Content-Type': 'application/json', 'X-QQQ-Client': 'next-ui' },
body: JSON.stringify({
connectionId: input.connectionId,
table: input.table,
-2
View File
@@ -16,7 +16,6 @@
/** @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'
@@ -41,7 +40,6 @@ export async function readRealtimeStream(
Accept: 'text/event-stream',
...CLIENT_CHANNEL_HEADER,
...userTimeZoneHeader(),
...userActivityHeader(),
...(lastEventId ? { 'Last-Event-ID': lastEventId } : {}),
},
}
-112
View File
@@ -1,112 +0,0 @@
/*
* 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
@@ -1,151 +0,0 @@
/*
* 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
}
+197 -3
View File
@@ -14,10 +14,15 @@
* limitations under the License.
*/
// Tests for query key factory
// Tests for the query key factory and the global error banner
import { describe, it, expect } from 'vitest'
import { queryKeys } from './query-client'
import React from 'react'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { act, cleanup, render, waitFor } from '@testing-library/react'
import { AxiosError, AxiosHeaders, CanceledError, type InternalAxiosRequestConfig } from 'axios'
import { Toaster, toast } from 'sonner'
import { HANDLES_OWN_ERRORS, queryClient, queryKeys } from './query-client'
describe('queryKeys factory', () => {
it('all() returns root key', () => {
@@ -109,3 +114,192 @@ describe('queryKeys factory', () => {
expect(k1).not.toBe(k2)
})
})
/**
* An axios error as the API client rejects with it. Its request carries a token, a body and a
* query string, none of which may reach a banner or the console.
*
* @param request - The request's method and URL (relative to the `/qqq/v1` base).
* @param response - The response's status and body; omit for a request that got no response.
* @param code - The axios error code.
* @returns The error.
*/
function axiosError(request: { method: string; url: string }, response?: { status: number; data?: unknown }, code = response ? 'ERR_BAD_REQUEST' : 'ERR_NETWORK'): AxiosError {
const config = { ...request, baseURL: '/qqq/v1', headers: new AxiosHeaders({ Authorization: 'Bearer secret-token' }), data: '{"name":"Private Person"}' } as InternalAxiosRequestConfig
const message = code === 'ECONNABORTED' ? 'timeout of 30000ms exceeded' : response ? `Request failed with status code ${response.status}` : 'Network Error'
return new AxiosError(message, code, config, {}, response ? { ...response, data: response.data, statusText: '', headers: {}, config } : undefined)
}
/**
* Fails a query through the shared client, as a component's `useQuery` would.
*
* @param error - What the query function rejects with.
* @param queryKey - The query's key.
* @param meta - The query's meta.
*/
async function failQuery(error: unknown, queryKey: readonly unknown[] = queryKeys.tableRecords('order'), meta?: Record<string, unknown>): Promise<void> {
await queryClient.fetchQuery({ queryKey, queryFn: () => Promise.reject(error), retry: false, staleTime: 0, meta }).catch(() => undefined)
}
/**
* Fails a mutation through the shared client, as a component's `useMutation` would.
*
* @param error - What the mutation function rejects with.
*/
async function failMutation(error: unknown): Promise<void> {
await queryClient.getMutationCache().build(queryClient, { mutationFn: () => Promise.reject(error) }).execute(undefined).catch(() => undefined)
}
describe('global error banner', () => {
const QUERY = { method: 'post', url: '/table/order/query?filter=%7B%22name%22%3A%22Private%20Person%22%7D' }
const banner = vi.spyOn(toast, 'error')
const warn = vi.spyOn(console, 'warn')
/**
* The banners requested so far.
*
* @returns Each banner's text and description.
*/
const banners = () => banner.mock.calls.map(([message, options]) => [message, options?.description])
/**
* The banners on screen.
*
* @returns Each banner's text.
*/
const shown = () => Array.from(document.querySelectorAll('[data-sonner-toast]')).map((element) => element.textContent)
beforeEach(() => {
banner.mockClear()
warn.mockClear().mockImplementation(() => undefined)
})
afterEach(() => {
cleanup()
toast.dismiss()
queryClient.clear()
})
it('says the server did not respond, and which request it was, without the query string', async () => {
await failQuery(axiosError(QUERY))
expect(banners()).toEqual([['No response from the server', 'POST /qqq/v1/table/order/query']])
})
it('says the server took too long when the request timed out', async () => {
await failQuery(axiosError({ method: 'get', url: '/jobs' }, undefined, 'ECONNABORTED'))
expect(banners()).toEqual([['The server took too long to respond', 'GET /qqq/v1/jobs']])
})
it('says the server did not respond when a fetch fails', async () => {
await failQuery(new TypeError('Failed to fetch'), ['qqq', 'realtime', 'subscription-7'])
await failQuery(new TypeError('Load failed'), queryKeys.tablePossibleValues('order', 'customerId', 'Private Person'))
expect(banners()).toEqual([
['No response from the server', 'While loading realtime'],
['No response from the server', 'While loading possible values'],
])
})
it("shows the server's own message for an unexpected status", async () => {
await failMutation(axiosError({ method: 'post', url: '/table/order' }, { status: 409, data: { error: 'This order was changed by someone else.' } }))
expect(banners()).toEqual([['This order was changed by someone else.', 'POST /qqq/v1/table/order (HTTP 409)']])
})
it('names the status when the server sent no message, never a non-JSON body', async () => {
await failMutation(axiosError({ method: 'put', url: '/table/order/42' }, { status: 413, data: '<html>Request Entity Too Large</html>' }))
await failQuery(axiosError(QUERY, { status: 400 }))
expect(banners()).toEqual([
['Request failed (HTTP 413)', 'PUT /qqq/v1/table/order/{id}'],
['Request failed (HTTP 400)', 'POST /qqq/v1/table/order/query'],
])
})
it('keeps the 403, 404 and 5xx wording and adds the request', async () => {
await failQuery(axiosError({ method: 'get', url: '/table/order/42' }, { status: 403, data: { error: 'Permission denied.' } }))
await failQuery(axiosError({ method: 'get', url: '/table/order/43' }, { status: 403, data: '<html>Forbidden</html>' }))
await failQuery(axiosError({ method: 'get', url: '/table/order/42' }, { status: 404 }))
await failQuery(axiosError({ method: 'get', url: '/metaData' }, { status: 502 }))
await failMutation(axiosError({ method: 'post', url: '/table/order' }, { status: 500 }))
expect(banners()).toEqual([
['Permission denied.', 'GET /qqq/v1/table/order/{id} (HTTP 403)'],
['Permission denied', 'GET /qqq/v1/table/order/{id} (HTTP 403)'],
['Resource not found', 'GET /qqq/v1/table/order/{id} (HTTP 404)'],
['Server error — retrying…', 'GET /qqq/v1/metaData (HTTP 502)'],
['Server error — please try again', 'POST /qqq/v1/table/order (HTTP 500)'],
])
})
it('masks every path segment that is not a plain word, and reads an absolute URL by its path', async () => {
await failQuery(axiosError({ method: 'get', url: '/jobs/3f2b8c1e-9d4a-4c6b-8e21-5a7d9c0b1f33/progress' }, { status: 409 }))
await failQuery(axiosError({ method: 'get', url: '/table/person/private.person%40example.com' }, { status: 409 }))
await failQuery(axiosError({ method: 'get', url: 'https://files.example.com/download/abc123def456?signature=secret-token#part' }, { status: 409 }))
expect(banners().map(([, description]) => description)).toEqual([
'GET /qqq/v1/jobs/{id}/progress',
'GET /qqq/v1/table/person/{id}',
'GET /download/{id}',
])
})
it("shows an application error's own sentence, and only the type of a runtime error", async () => {
await failQuery(new Error('Invalid audit response'))
await failQuery(new TypeError("Cannot read properties of undefined (reading 'Private Person')"), ['qqq', 'search', 'records', 'Private Person'])
await failMutation('not an error object')
await failMutation(undefined)
expect(banners()).toEqual([
['Invalid audit response', 'While loading records'],
['The page hit an unexpected error (TypeError)', 'While loading search'],
['not an error object', undefined],
['The page hit an unexpected error', undefined],
])
})
it('shows one banner for a failure that repeats, and another for a different failure', async () => {
render(React.createElement(Toaster))
// a polling query failing on every attempt
for (let attempt = 0; attempt < 3; attempt++) await act(() => failQuery(axiosError(QUERY, { status: 400 })))
await waitFor(() => expect(shown()).toHaveLength(1))
expect(shown()[0]).toContain('Request failed (HTTP 400)')
// the same status from another endpoint is a different failure; sonner shows banners in
// the order they were requested, so by now all three repeats have been applied
await act(() => failQuery(axiosError({ method: 'get', url: '/jobs' }, { status: 400 }), queryKeys.jobs()))
await waitFor(() => expect(shown()).toHaveLength(2))
expect(banner).toHaveBeenCalledTimes(4)
expect(new Set(banner.mock.calls.map(([, options]) => options?.id)).size).toBe(2)
})
it('shows one banner for a lost connection, whichever requests met it', async () => {
render(React.createElement(Toaster))
await act(() => failQuery(axiosError(QUERY)))
await act(() => failQuery(axiosError({ method: 'get', url: '/jobs' }), queryKeys.jobs()))
await act(() => failQuery(axiosError({ method: 'get', url: '/alerts/inbox/summary' }), queryKeys.alertsInbox()))
// the banner names the request that failed most recently
await waitFor(() => expect(shown()[0]).toContain('GET /qqq/v1/alerts/inbox/summary'))
expect(shown()).toHaveLength(1)
expect(shown()[0]).toContain('No response from the server')
})
it('stays silent for a cancelled request, a 401 and a query that shows its own error', async () => {
await failQuery(new CanceledError('canceled'))
await failQuery(new DOMException('The operation was aborted.', 'AbortError'))
await failQuery(axiosError({ method: 'get', url: '/metaData' }, { status: 401, data: { error: 'Session expired' } }))
await failMutation(axiosError({ method: 'post', url: '/table/order' }, { status: 401 }))
await failQuery(axiosError(QUERY), queryKeys.tableRecords('order'), HANDLES_OWN_ERRORS)
expect(banner).not.toHaveBeenCalled()
expect(warn).not.toHaveBeenCalled()
})
it('logs the technical detail once per failure, without message, body, headers or query string', async () => {
await failQuery(axiosError(QUERY, { status: 409, data: { error: 'Private Person is locked', record: { name: 'Private Person' } } }))
await failQuery(axiosError(QUERY))
expect(warn.mock.calls).toEqual([
['[API] Request failed:', { context: 'query', status: 409, code: 'ERR_BAD_REQUEST', name: 'AxiosError', request: 'POST /qqq/v1/table/order/query', area: 'records' }],
['[API] Request failed:', { context: 'query', status: undefined, code: 'ERR_NETWORK', name: 'AxiosError', request: 'POST /qqq/v1/table/order/query', area: 'records' }],
])
expect(JSON.stringify(warn.mock.calls)).not.toMatch(/Private|secret-token|filter/)
expect(JSON.stringify(banners()[1])).not.toMatch(/Private|secret-token|filter/)
})
})
+112 -30
View File
@@ -20,23 +20,107 @@
import { QueryClient, QueryCache, MutationCache, defaultShouldDehydrateQuery } from '@tanstack/react-query'
import { toast } from 'sonner'
import { isCancel } from 'axios'
import { AxiosError, isCancel } from 'axios'
import { getErrorStatusCode, getErrorMessage } from '@/lib/utils/error-utils'
/** JavaScript's own error types (and schema errors): their messages are written for developers. */
const RUNTIME_ERROR = /^(Type|Range|Reference|Syntax|Eval|URI|Aggregate|Internal|Zod)Error$/
/** How a browser words a `fetch` that got no response (Chrome, Firefox, Safari). */
const FETCH_NO_RESPONSE = /failed to fetch|networkerror|load failed/i
/**
* Returns a toast message for a query/mutation error based on HTTP status.
* The request an error came from, as `METHOD /path`, for a banner and the console. The query
* string is dropped, and every path segment that is not a plain word (a record id, a UUID, an
* encoded value) becomes `{id}`, so no record data or token is shown and one endpoint has one text.
*
* @param error - The unknown error thrown by the query or mutation.
* @returns The request, or `undefined` when the error carries none.
*/
function requestLine(error: unknown): string | undefined {
const config = error instanceof AxiosError ? error.config : undefined
if (!config?.url) return undefined
const absolute = /^[a-z][a-z\d+.-]*:\/\//i.test(config.url)
try {
const { pathname } = new URL(absolute ? config.url : `${config.baseURL ?? ''}${config.url.startsWith('/') ? '' : '/'}${config.url}`, 'http://localhost')
const path = pathname.split('/').filter(Boolean).map((segment) => (/^[A-Za-z][A-Za-z_-]{0,39}\d{0,2}$/.test(segment) ? segment : '{id}')).join('/')
return `${config.method ? `${config.method.toUpperCase()} ` : ''}/${path}`
} catch {
return undefined
}
}
/**
* The server's own explanation of a failed request, read from the response body only (never
* axios' generic "Request failed with status code 400", and never a non-JSON body).
*
* @param error - The unknown error thrown by the query or mutation.
* @returns The message, or an empty string when the server sent none.
*/
function serverMessage(error: unknown): string {
const body: unknown = error instanceof AxiosError ? error.response?.data : error
return body && typeof body === 'object' ? getErrorMessage(body, '') : ''
}
/**
* Whether a request ended without any response (the connection failed, or the request timed out).
*
* @param error - The unknown error thrown by the query or mutation.
* @returns True for an axios error with no response and for a failed `fetch`.
*/
function gotNoResponse(error: unknown): boolean {
return error instanceof AxiosError ? !error.response : error instanceof TypeError && FETCH_NO_RESPONSE.test(error.message)
}
/**
* What the banner says about a failed query or mutation.
*
* @param error - The unknown error thrown by the query or mutation.
* @param status - Its HTTP status, when it has one.
* @param context - `'query'` or `'mutation'` — selects message wording.
* @returns The banner's text.
*/
function failureMessage(error: unknown, status: number | undefined, context: 'query' | 'mutation'): string {
if (status === 403) return serverMessage(error) || 'Permission denied'
if (status === 404) return 'Resource not found'
if (status !== undefined && status >= 500) return context === 'mutation' ? 'Server error — please try again' : 'Server error — retrying…'
// Any other status (400, 409, 422, …): the server's own words when it sent any.
if (status !== undefined) return serverMessage(error) || `Request failed (HTTP ${status})`
if (gotNoResponse(error)) {
const code = error instanceof AxiosError ? error.code : undefined
const timedOut = code === 'ETIMEDOUT' || (code === 'ECONNABORTED' && /timeout/i.test((error as Error).message))
return timedOut ? 'The server took too long to respond' : 'No response from the server'
}
// The page failed before or after the request. An error the application raised carries a
// sentence written for the user; a runtime error's message is written for developers.
const name = error instanceof Error ? error.name : undefined
const written = name && RUNTIME_ERROR.test(name) ? '' : getErrorMessage(error, '')
return written || `The page hit an unexpected error${name ? ` (${name})` : ''}`
}
/**
* Shows one error banner for a failed query or mutation, and logs its technical detail.
*
* Every banner says what failed: the request (`METHOD /path`, see {@link requestLine}) or, for a
* query that failed outside a request, the area it was loading. The banner's id is that text, so
* the same failure repeating (a polling query that keeps failing) updates the banner already on
* screen instead of adding one. A lost connection is one cause whichever request met it, so it
* has one banner, which names the request that failed most recently.
*
* 401 responses are silently ignored — the axios interceptor already redirects
* to login, so surfacing a toast would be redundant and confusing.
*
* @param error - The unknown error thrown by the query or mutation.
* @param context - `'query'` or `'mutation'` — selects message wording.
* @param queryKey - The failed query's key; only its namespace (a word from the code) is read.
*/
function handleQueryError(error: unknown, context: 'query' | 'mutation'): void {
// A cancelled request (aborted, or discarded because the session ended at logout) has no
// result to report.
if (isCancel(error)) return
function handleQueryError(error: unknown, context: 'query' | 'mutation', queryKey?: readonly unknown[]): void {
// A cancelled request (an axios cancel, an aborted `fetch`, or a response discarded because
// the session ended at logout) has no result to report.
if (isCancel(error) || (error as { name?: unknown } | null)?.name === 'AbortError') return
const status = getErrorStatusCode(error)
@@ -45,31 +129,29 @@ function handleQueryError(error: unknown, context: 'query' | 'mutation'): void {
return
}
if (status === 403) {
toast.error(getErrorMessage(error, 'Permission denied'))
return
}
const message = failureMessage(error, status, context)
const request = requestLine(error)
const namespace = queryKey?.[0] === 'qqq' ? queryKey[1] : queryKey?.[0]
const area = typeof namespace === 'string' && /^[A-Za-z][A-Za-z-]*$/.test(namespace) ? namespace : undefined
const http = status !== undefined && !message.includes(`HTTP ${status}`) ? `HTTP ${status}` : undefined
// `possibleValues` and `release-notes` read as "possible values" and "release notes".
const areaWords = area?.replace(/([a-z])([A-Z])/g, '$1 $2').replace(/-/g, ' ').toLowerCase()
const where = request ?? (areaWords ? `While loading ${areaWords}` : undefined)
const description = where && http ? `${where} (${http})` : (where ?? http)
if (status === 404) {
toast.error('Resource not found')
return
}
// For a developer: no message, body, header or query string, which can carry record data.
const code = (error as { code?: unknown } | null)?.code
console.warn('[API] Request failed:', {
context,
status,
code: typeof code === 'string' ? code : undefined,
name: error instanceof Error ? error.name : undefined,
request,
area,
})
if (status !== undefined && status >= 500) {
if (context === 'mutation') {
toast.error('Server error — please try again')
} else {
toast.error('Server error — retrying…')
}
return
}
// Network failure (no status) or unexpected status codes.
if (context === 'mutation') {
toast.error('Something went wrong')
} else {
toast.error('Something went wrong')
}
const sameForEveryRequest = status === undefined && gotNoResponse(error)
toast.error(message, { id: `request-failed:${message}:${sameForEveryRequest ? '' : (description ?? '')}`, description })
}
/**
@@ -134,7 +216,7 @@ export const queryClient = new QueryClient({
queryCache: new QueryCache({
// Queries that render their own error state (records, widgets) opt out with HANDLES_OWN_ERRORS.
onError: (error, query) => {
if (!handlesOwnErrors(query.meta)) handleQueryError(error, 'query')
if (!handlesOwnErrors(query.meta)) handleQueryError(error, 'query', query.queryKey)
},
}),
mutationCache: new MutationCache({
+43
View File
@@ -0,0 +1,43 @@
/*
* 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 { expect, it } from 'vitest'
import { continuationUrl } from './external-navigation'
const AUTHORIZE =
'https://slack.com/oauth/v2/authorize?client_id=1.2&scope=&user_scope=chat%3Awrite&redirect_uri=https%3A%2F%2Fapp.example.test%2Fqqq%2Fhooks%2Fslack%2Foauth%2Fcallback&state=abc.def'
it('recognizes an answer that is only an absolute https address, and returns it unchanged', () => {
expect(continuationUrl(AUTHORIZE)).toBe(AUTHORIZE)
expect(continuationUrl(` ${AUTHORIZE}\n`)).toBe(AUTHORIZE)
})
it.each([
['an ordinary message', 'Slack disconnected.'],
['a sentence that contains an address', `Open ${AUTHORIZE}`],
['two addresses', `${AUTHORIZE} ${AUTHORIZE}`],
['plain http', 'http://slack.com/oauth/v2/authorize'],
['a script address', 'javascript:alert(1)'],
['a data address', 'data:text/html,<p>x</p>'],
['a protocol-relative address', '//slack.com/oauth'],
['a local path', '/app/preferences'],
['credentials in the address', 'https://user:secret@slack.com/oauth'],
['no host', 'https://'],
['an empty answer', ''],
['no answer', undefined],
])('leaves %s as a message', (_name, text) => {
expect(continuationUrl(text)).toBeNull()
})
+49
View File
@@ -0,0 +1,49 @@
/*
* 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 external-navigation — leaving the dashboard for another site, such as the consent
* page of a service the user is connecting. An address is something to go to, never text to
* show: a backend answer that is only an address is recognized here and opened.
*/
/**
* The address a backend answer names when the answer is nothing but one absolute `https:`
* address: no other words, no whitespace, no credentials in it. A sentence that merely
* contains an address is an ordinary message and is not matched.
*
* @param text - The backend's answer (an action result message, for example).
* @returns The address exactly as sent, or `null` when the answer is anything else.
*/
export function continuationUrl(text: string | null | undefined): string | null {
const address = text?.trim()
if (!address || /\s/.test(address) || !/^https:\/\//i.test(address)) return null
try {
const url = new URL(address)
return url.protocol === 'https:' && url.hostname && !url.username && !url.password ? address : null
} catch {
return null
}
}
/**
* Sends the browser to another site in this tab, so the site can return the user here.
*
* @param url - An absolute address (see {@link continuationUrl}).
*/
export function leaveTo(url: string): void {
window.location.assign(url)
}
-5
View File
@@ -96,11 +96,6 @@ 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
+5
View File
@@ -91,6 +91,11 @@ export interface PreferenceSecret {
/** Only section is safe to retain in the preferences cache. */
export interface PreferenceActionResponse {
section: PreferenceSection
/**
* The outcome, shown once. The UI API has no redirect field, so a message that is nothing but
* one absolute `https:` address is where the browser goes next (an OAuth consent page) and is
* never shown; see `continuationUrl`.
*/
message?: string
secrets?: PreferenceSecret[]
focusRowKey?: string