Compare commits

...
4 Commits
Author SHA1 Message Date
james.maes e58db9d5e9 Merge remote-tracking branch 'origin/feature/user-preferences' into feature/process-one-time-secrets 2026-10-14 08:12:10 -05:00
james.maes 5b27bb3989 Merge remote-tracking branch 'origin/feature/user-preferences' into feature/process-one-time-secrets 2026-10-14 07:45:07 -05:00
james.maesandClaude Opus 5.5 4e00e7f644 feat(process): one-time secrets from the first completed status poll
A step that outlives the step timeout goes async; core now hands its
one-time secrets to the first status poll that sees the job complete
(QRun-IO/qqq#1174). The poll path splits them off exactly like the
synchronous path, so process state never holds them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 22:48:01 -05:00
james.maesandClaude Opus 5.5 8b9b14e2e5 feat(process): show one-time secrets on the Result screen
When a synchronous process init or step response carries oneTimeSecrets
(QRun-IO/qqq#1174), the Result screen opens the shared show-once dialog
(OneTimeSecretDialog, lazy loaded). Secrets live only in hook state,
are stripped from the response before it reaches process state, and
are cleared on close, restart, cancel and failure. A reload shows the
masked placeholders from oneTimeSecretsMasked ("Shown once").

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:13:15 -05:00
7 changed files with 202 additions and 7 deletions

No files matched your search

+22 -1
View File
@@ -46,6 +46,25 @@ export interface ProcessResultStepProps {
actions?: React.ReactNode
}
/** Render only the retained masked placeholders on final or metadata-defined result screens.
* @param props - Safe process values.
* @returns An accessible notice, or nothing when no placeholders exist.
*/
export function ProcessSecretNotice({ values }: { values: Record<string, unknown> }) {
const maskedSecrets = Array.isArray(values.oneTimeSecretsMasked)
? values.oneTimeSecretsMasked.filter((value): value is string => typeof value === 'string')
: []
if (!maskedSecrets.length) return null
return (
<section aria-label="One-time secrets" role="status" className="rounded-lg border border-border bg-muted p-4 text-left text-sm text-foreground">
<p>Shown once. It can&apos;t be displayed again.</p>
<ul className="mt-2 space-y-1">
{maskedSecrets.map((masked, index) => <li key={index} className="break-all font-mono">{masked}</li>)}
</ul>
</section>
)
}
/** A single numeric stat entry to display in the completion summary. */
interface ResultStat {
/** Human-readable label for the stat (e.g. "Records Inserted"). */
@@ -153,12 +172,14 @@ export function ProcessResultStep({
{/* Success heading */}
<div>
<h3 className="text-xl font-semibold text-foreground">
<h3 className="text-xl font-semibold text-foreground" tabIndex={-1} data-qqq-id="process-result-heading">
{processMetaData?.label ?? 'Process'} Complete
</h3>
<p className="mt-2 text-sm text-muted-foreground">{successMessage}</p>
</div>
<ProcessSecretNotice values={resultValues} />
{/* Result stats */}
{stats.length > 0 && (
<div
+2 -2
View File
@@ -29,10 +29,10 @@ vi.mock('next/navigation', () => ({ useRouter: () => ({ push: vi.fn() }) }))
vi.mock('@/lib/api/metadata', () => ({ loadMetaData: vi.fn().mockResolvedValue({ apps: {}, appTree: [] }) }))
vi.mock('@/lib/hooks/use-metadata', () => ({ useTableMetaData: () => ({ data: undefined }) }))
vi.mock('@/lib/hooks/use-process', () => ({
useProcess: () => ({ state: processState.current, start: vi.fn(), submit: vi.fn(), back: vi.fn(), cancel: vi.fn() }),
useProcess: () => ({ state: processState.current, oneTimeSecrets: [], clearOneTimeSecrets: vi.fn(), start: vi.fn(), submit: vi.fn(), back: vi.fn(), cancel: vi.fn() }),
}))
vi.mock('./ProcessStepScreen', () => ({ ProcessStepScreen: () => <p>step</p> }))
vi.mock('./ProcessResultStep', () => ({ ProcessResultStep: () => <p>done</p> }))
vi.mock('./ProcessResultStep', () => ({ ProcessResultStep: () => <p>done</p>, ProcessSecretNotice: () => null }))
import { ProcessRun } from './ProcessRun'
+16 -2
View File
@@ -26,6 +26,7 @@
import React, { useEffect, useMemo, useRef, useState } from 'react'
import { useRouter } from 'next/navigation'
import dynamic from 'next/dynamic'
import { useQuery } from '@tanstack/react-query'
import { Loader2 } from 'lucide-react'
@@ -41,9 +42,14 @@ import { readStoredTableVariant } from '@/lib/utils/table-variant'
import { StepWizard } from './StepWizard'
import { ProcessCancelDialog } from './ProcessCancelDialog'
import { ProcessErrorState } from './ProcessErrorState'
import { ProcessResultStep } from './ProcessResultStep'
import { ProcessResultStep, ProcessSecretNotice } from './ProcessResultStep'
import { ProcessStepScreen } from './ProcessStepScreen'
const OneTimeSecretDialog = dynamic(
() => import('@/components/preferences/PreferenceActions').then((module) => module.OneTimeSecretDialog),
{ ssr: false }
)
/**
* Props for the {@link ProcessRun} component.
*/
@@ -164,7 +170,7 @@ export function ProcessRun({
}), [initialRequest, initialValues, processMetaData.tableName])
const boundsMessage = useMemo(() => inputRecordBoundsMessage(processMetaData, request), [processMetaData, request])
const { state, start, submit, back, cancel } = useProcess(processName, processMetaData, request)
const { state, oneTimeSecrets, clearOneTimeSecrets, start, submit, back, cancel } = useProcess(processName, processMetaData, request)
const [confirmCancel, setConfirmCancel] = useState(false)
const startedRef = useRef(false)
const containerRef = useRef<HTMLDivElement>(null)
@@ -220,6 +226,13 @@ export function ProcessRun({
>
<div className={isEmbedded ? '' : 'overflow-hidden rounded-xl border border-border bg-card shadow-sm'}>{children}</div>
<ProcessCancelDialog open={confirmCancel} onOpenChange={setConfirmCancel} onConfirm={() => { void cancelAndLeave() }} />
{(state.phase === 'complete' || state.phase === 'step') && oneTimeSecrets.length > 0 && (
<OneTimeSecretDialog
secrets={oneTimeSecrets}
onClose={clearOneTimeSecrets}
onReturnFocus={() => containerRef.current?.querySelector<HTMLElement>('[data-qqq-id="process-result-heading"], [data-qqq-id="process-step-heading"]')?.focus()}
/>
)}
</div>
)
@@ -357,6 +370,7 @@ export function ProcessRun({
onCancel={() => setConfirmCancel(true)}
onReturn={leave}
/>
<ProcessSecretNotice values={state.values} />
</>
)
}
@@ -0,0 +1,125 @@
/*
* 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 One-time process secrets: synchronous handoff, dismissal and retained placeholders. */
import React from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { act, render, renderHook, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import type { QProcessMetaData } from '@/types'
import apiClient from '@/lib/api/client'
import { useProcess } from '@/lib/hooks/use-process'
import { ProcessRun } from './ProcessRun'
vi.mock('next/navigation', () => ({ useRouter: () => ({ push: vi.fn() }) }))
vi.mock('@/lib/api/metadata', () => ({ loadMetaData: vi.fn().mockResolvedValue({ apps: {}, appTree: [] }) }))
vi.mock('@/lib/hooks/use-metadata', () => ({ useTableMetaData: () => ({ data: undefined }) }))
vi.mock('@/lib/api/client', () => ({ default: { post: vi.fn(), get: vi.fn() }, apiUrl: (path: string) => path }))
const process: QProcessMetaData = {
name: 'createToken', label: 'Create token', tableName: '', minInputRecords: 0, isHidden: false, iconName: '', hasPermission: true,
stepFlow: 'LINEAR',
frontendSteps: [
{ name: 'input', label: 'Input', components: [] },
{ name: 'result', label: 'Result', components: [] },
],
}
const secret = { label: 'API token', value: 'test-only-one-time-value', masked: 'token-****abcd', usageText: 'test-only-usage' }
/** Render a real process lifecycle with an observable query client.
* @returns The rendered view and cache.
*/
function renderRun() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
const view = render(<QueryClientProvider client={client}><ProcessRun processName={process.name} processMetaData={process} /></QueryClientProvider>)
return { ...view, client }
}
beforeEach(() => { vi.clearAllMocks() })
describe('process one-time secrets', () => {
it.each(['init', 'step'] as const)('shows %s secrets once and keeps caches clean', async (source) => {
const user = userEvent.setup()
if (source === 'step') vi.mocked(apiClient.post).mockResolvedValueOnce({ processUUID: 'run', values: {}, nextStep: 'input' })
vi.mocked(apiClient.post).mockResolvedValueOnce({ type: 'COMPLETE', processUUID: 'run', values: { oneTimeSecretsMasked: [secret.masked] }, oneTimeSecrets: [secret] })
const { client } = renderRun()
if (source === 'step') await user.click(await screen.findByRole('button', { name: /Next|Submit/ }))
const dialog = await screen.findByRole('dialog', { name: 'Show once' })
expect(within(dialog).getByRole('textbox', { name: secret.label })).toHaveValue(secret.value)
expect(within(dialog).getByText(secret.usageText)).toBeInTheDocument()
const cache = () => JSON.stringify([
client.getQueryCache().getAll().map((query) => query.state),
client.getMutationCache().getAll().map((mutation) => mutation.state),
])
expect(cache()).not.toContain(secret.value)
expect(cache()).not.toContain(secret.usageText)
await user.click(within(dialog).getByRole('checkbox', { name: "I've copied it" }))
await user.click(within(dialog).getByRole('button', { name: 'Done' }))
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
expect(screen.queryByDisplayValue(secret.value)).not.toBeInTheDocument()
expect(screen.getByText("Shown once. It can't be displayed again.")).toBeInTheDocument()
expect(screen.getByText(secret.masked)).toBeInTheDocument()
expect(cache()).not.toContain(secret.value)
expect(screen.getByRole('heading', { name: 'Create token Complete' })).toHaveFocus()
})
it.each([undefined, []])('does not open for absent or empty secrets (%s)', async (oneTimeSecrets) => {
vi.mocked(apiClient.post).mockResolvedValueOnce({ processUUID: 'run', values: { oneTimeSecretsMasked: ['****old', '****other'] }, oneTimeSecrets })
renderRun()
await screen.findByRole('heading', { name: 'Create token Complete' })
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
expect(screen.getByText('****old')).toBeInTheDocument()
expect(screen.getByText('****other')).toBeInTheDocument()
})
it('shows the dialog and masked notice on a metadata-defined Result screen', async () => {
vi.mocked(apiClient.post).mockResolvedValueOnce({ processUUID: 'run', nextStep: 'result', values: { oneTimeSecretsMasked: [secret.masked] }, oneTimeSecrets: [secret] })
renderRun()
expect(await screen.findByRole('dialog', { name: 'Show once' })).toBeInTheDocument()
expect(screen.getByText("Shown once. It can't be displayed again.")).toBeInTheDocument()
})
it('stores only sanitized process state and clears the separate dialog state', async () => {
vi.mocked(apiClient.post).mockResolvedValueOnce({ processUUID: 'run', values: { oneTimeSecretsMasked: [secret.masked] }, oneTimeSecrets: [secret] })
const { result } = renderHook(() => useProcess(process.name, process, {}))
await act(async () => result.current.start())
expect(result.current.oneTimeSecrets).toEqual([secret])
expect(JSON.stringify(result.current.state)).not.toContain(secret.value)
expect(result.current.state).not.toHaveProperty('oneTimeSecrets')
act(() => result.current.clearOneTimeSecrets())
expect(result.current.oneTimeSecrets).toEqual([])
expect(JSON.stringify(result.current)).not.toContain(secret.value)
})
it('shows secrets handed to the first completed status poll after the step went async', async () => {
vi.mocked(apiClient.post).mockResolvedValueOnce({ type: 'JOB_STARTED', processUUID: 'run', jobUUID: 'job' })
vi.mocked(apiClient.get).mockResolvedValueOnce({ type: 'COMPLETE', processUUID: 'run', values: { oneTimeSecretsMasked: [secret.masked] }, oneTimeSecrets: [secret] })
const { result } = renderHook(() => useProcess(process.name, process, {}))
await act(async () => result.current.start())
await waitFor(() => expect(result.current.oneTimeSecrets).toEqual([secret]), { timeout: 5000 })
expect(JSON.stringify(result.current.state)).not.toContain(secret.value)
})
it('renders ordinary results without a dialog or masked notice', async () => {
vi.mocked(apiClient.post).mockResolvedValueOnce({ processUUID: 'run', values: {} })
renderRun()
await screen.findByRole('heading', { name: 'Create token Complete' })
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
expect(screen.queryByText("Shown once. It can't be displayed again.")).not.toBeInTheDocument()
})
})
+4
View File
@@ -29,6 +29,7 @@
import { AxiosError } from 'axios'
import type { ProcessMetaDataAdjustment, QRecord } from '@/types'
import type { PreferenceSecret } from '@/types/preferences'
import apiClient, { apiUrl } from './client'
/** Files to upload with a process request, keyed by the form field name (e.g. `theFile`). */
@@ -87,6 +88,8 @@ export interface ProcessCompleteResponse {
nextStep?: string
backStep?: string
processMetaDataAdjustment?: ProcessMetaDataAdjustment
/** Synchronous init/step only; extract before storing the response. */
oneTimeSecrets?: PreferenceSecret[]
}
/** The request continued as an async job, to be polled via {@link processStatus}. */
@@ -243,6 +246,7 @@ export function normalizeProcessResponse(raw: unknown): ProcessResponse {
nextStep: typeof data.nextStep === 'string' ? data.nextStep : undefined,
backStep: typeof data.backStep === 'string' ? data.backStep : undefined,
processMetaDataAdjustment,
...(Array.isArray(data.oneTimeSecrets) ? { oneTimeSecrets: data.oneTimeSecrets as PreferenceSecret[] } : {}),
}
}
const jobStatus = data.jobStatus && typeof data.jobStatus === 'object' ? data.jobStatus as Record<string, unknown> : null
+30 -2
View File
@@ -24,6 +24,7 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import type { QFieldMetaData, QFrontendStepMetaData, QProcessMetaData } from '@/types'
import type { PreferenceSecret } from '@/types/preferences'
import {
processCancel,
processInit,
@@ -92,6 +93,10 @@ export interface ProcessState {
/** Result of {@link useProcess}. */
export interface UseProcessReturn {
state: ProcessState
/** Ephemeral dialog data, separate from the stored process state. */
oneTimeSecrets: PreferenceSecret[]
/** Discard the one-time values on dialog dismissal. */
clearOneTimeSecrets: () => void
/** Start (or restart, for Retry) the process with the original init request. */
start: () => void
/** Submit the current screen's values (and uploads) and continue. */
@@ -135,6 +140,8 @@ export function useProcess(
processMetaData: QProcessMetaData,
initialRequest: ProcessInitRequest
): UseProcessReturn {
const [oneTimeSecrets, setOneTimeSecrets] = useState<PreferenceSecret[]>([])
const clearOneTimeSecrets = useCallback(() => setOneTimeSecrets([]), [])
const [state, setState] = useState<ProcessState>(() => ({
phase: 'idle',
processUUID: null,
@@ -178,6 +185,7 @@ export function useProcess(
}, [])
const fail = useCallback((message: string, isUserFacing: boolean) => {
setOneTimeSecrets([])
clearPoll()
setState((previous) => ({ ...previous, phase: 'error', jobUUID: null, progress: null, error: { message, isUserFacing } }))
}, [clearPoll])
@@ -263,6 +271,14 @@ export function useProcess(
fail('Unexpected server response.', false)
return
}
if (response.type === 'COMPLETE') {
// a step that went async hands its one-time secrets to the first
// completed status poll (QRun-IO/qqq#1174); same isolation as run()
const { oneTimeSecrets: secrets, ...safeResponse } = response
if (secrets?.length) setOneTimeSecrets(secrets)
handle(safeResponse, generation)
return
}
handle(response, generation)
} catch (error) {
if (generation !== generationRef.current) return
@@ -289,7 +305,17 @@ export function useProcess(
*/
const run = useCallback(async (request: () => Promise<ProcessResponse>, generation: number) => {
try {
handle(await request(), generation)
const response = await request()
if (generation !== generationRef.current || !mountedRef.current) return
if (response.type === 'COMPLETE') {
// Only the dialog state receives secrets. State updater closures receive
// the sanitized response, never the original synchronous response.
const { oneTimeSecrets: secrets, ...safeResponse } = response
if (secrets?.length) setOneTimeSecrets(secrets)
handle(safeResponse, generation)
} else {
handle(response, generation)
}
} catch (error) {
if (generation !== generationRef.current) return
if (getErrorStatusCode(error) === 401) return
@@ -298,6 +324,7 @@ export function useProcess(
}, [fail, handle])
const start = useCallback(() => {
setOneTimeSecrets([])
generationRef.current += 1
const generation = generationRef.current
clearPoll()
@@ -327,6 +354,7 @@ export function useProcess(
}, [processName, run])
const cancel = useCallback(async () => {
setOneTimeSecrets([])
const { processUUID } = stateRef.current
generationRef.current += 1
clearPoll()
@@ -341,5 +369,5 @@ export function useProcess(
}
}, [clearPoll, processName])
return { state, start, submit, back, cancel }
return { state, oneTimeSecrets, clearOneTimeSecrets, start, submit, back, cancel }
}
+3
View File
@@ -21,6 +21,7 @@
// QQQ Process Job Types - ported from qqq-frontend-core
import type { QFrontendStepMetaData, QFieldMetaData } from './metadata'
import type { PreferenceSecret } from '@/types/preferences'
/**
* Initial response returned when a process job is successfully submitted.
@@ -59,6 +60,8 @@ export interface QJobRunning {
* the wizard (next / back step names), and any runtime metadata adjustments.
*/
export interface QJobComplete {
/** Synchronous init/step only; never retain in process state or caches. */
oneTimeSecrets?: PreferenceSecret[]
/** UUID of the process instance that finished. */
processUUID: string
/** Key/value map of output values produced by the completed step. */