Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e58db9d5e9
|
||
|
|
5b27bb3989
|
||
|
|
4e00e7f644
|
||
|
|
8b9b14e2e5
|
No files matched your search
@@ -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'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
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
Reference in new issue
Block a user