diff --git a/web/default/src/components/long-text.tsx b/web/default/src/components/long-text.tsx index 353eed3d..531ab8c2 100644 --- a/web/default/src/components/long-text.tsx +++ b/web/default/src/components/long-text.tsx @@ -46,7 +46,6 @@ export function LongText({ useEffect(() => { if (checkOverflow(ref.current)) { - // eslint-disable-next-line react-hooks/set-state-in-effect setIsOverflown(true) return } diff --git a/web/default/src/features/auth/secure-verification/components/secure-verification-provider.tsx b/web/default/src/features/auth/secure-verification/components/secure-verification-provider.tsx index 21275666..9cb2e6f6 100644 --- a/web/default/src/features/auth/secure-verification/components/secure-verification-provider.tsx +++ b/web/default/src/features/auth/secure-verification/components/secure-verification-provider.tsx @@ -16,62 +16,35 @@ along with this program. If not, see . For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues */ -import { - createContext, - useCallback, - useContext, - useMemo, - type ReactElement, - type ReactNode, -} from 'react' +import { useCallback, useMemo, type ReactElement, type ReactNode } from 'react' import { useSecureVerification } from '../hooks/use-secure-verification' -import type { StartVerificationOptions, VerificationMethod } from '../types' +import { SecureVerificationContext } from '../lib/verification-context' +import type { VerificationMethod } from '../types' import { SecureVerificationDialog } from './secure-verification-dialog' type SecureVerificationProviderProps = { children: ReactNode } -interface SecureVerificationContextValue { - withVerification: ( - apiCall: () => Promise, - config?: StartVerificationOptions - ) => Promise -} - -export const SecureVerificationContext = - createContext(null) - -export function useSecureVerificationGate(): SecureVerificationContextValue { - const context = useContext(SecureVerificationContext) - if (!context) { - throw new Error( - 'useSecureVerificationGate must be used within SecureVerificationProvider' - ) - } - return context -} - export function SecureVerificationProvider( props: SecureVerificationProviderProps ): ReactElement { const secureVerification = useSecureVerification() + const executeVerification = secureVerification.executeVerification + const withVerification = secureVerification.withVerification const handleVerification = useCallback( async (method: VerificationMethod, code?: string): Promise => { try { - await secureVerification.executeVerification(method, code) + await executeVerification(method, code) } catch { // The shared verification hook reports the error and keeps retryable // verification failures inside the dialog. } }, - [secureVerification.executeVerification] - ) - const contextValue = useMemo( - () => ({ withVerification: secureVerification.withVerification }), - [secureVerification.withVerification] + [executeVerification] ) + const contextValue = useMemo(() => ({ withVerification }), [withVerification]) return ( diff --git a/web/default/src/features/auth/secure-verification/hooks/use-api-token-verification.ts b/web/default/src/features/auth/secure-verification/hooks/use-api-token-verification.ts index 2ea25b09..bf3d6ec4 100644 --- a/web/default/src/features/auth/secure-verification/hooks/use-api-token-verification.ts +++ b/web/default/src/features/auth/secure-verification/hooks/use-api-token-verification.ts @@ -18,7 +18,7 @@ For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API */ import { useCallback } from 'react' import { useTranslation } from 'react-i18next' -import { useSecureVerificationGate } from '../components/secure-verification-provider' +import { useSecureVerificationGate } from './use-secure-verification-gate' const defaultApiTokenVerificationDescription = 'Confirm your identity before sending an API key to a chat client.' @@ -27,15 +27,15 @@ export function useApiTokenVerification( description?: string ): (apiCall: () => Promise) => Promise { const { t } = useTranslation() - const secureVerificationGate = useSecureVerificationGate() + const withVerification = useSecureVerificationGate().withVerification return useCallback( (apiCall: () => Promise): Promise => - secureVerificationGate.withVerification(apiCall, { + withVerification(apiCall, { scope: 'api_token', title: t('Security verification'), description: description ?? t(defaultApiTokenVerificationDescription), }), - [description, t, secureVerificationGate.withVerification] + [description, t, withVerification] ) } diff --git a/web/default/src/features/auth/secure-verification/hooks/use-secure-verification-gate.ts b/web/default/src/features/auth/secure-verification/hooks/use-secure-verification-gate.ts new file mode 100644 index 00000000..44b11703 --- /dev/null +++ b/web/default/src/features/auth/secure-verification/hooks/use-secure-verification-gate.ts @@ -0,0 +1,33 @@ +/* +Copyright (C) 2023-2026 MAX-API-Next + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues +*/ +import { useContext } from 'react' +import { + SecureVerificationContext, + type SecureVerificationContextValue, +} from '../lib/verification-context' + +export function useSecureVerificationGate(): SecureVerificationContextValue { + const context = useContext(SecureVerificationContext) + if (!context) { + throw new Error( + 'useSecureVerificationGate must be used within SecureVerificationProvider' + ) + } + return context +} diff --git a/web/default/src/features/auth/secure-verification/hooks/verification-context.runtime.test.tsx b/web/default/src/features/auth/secure-verification/hooks/verification-context.runtime.test.tsx new file mode 100644 index 00000000..197e6968 --- /dev/null +++ b/web/default/src/features/auth/secure-verification/hooks/verification-context.runtime.test.tsx @@ -0,0 +1,184 @@ +/* +Copyright (C) 2023-2026 MAX-API-Next + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues +*/ +import { act, useEffect } from 'react' +import type { Root } from 'react-dom/client' +import { createInstance } from 'i18next' +import { JSDOM } from 'jsdom' +import assert from 'node:assert/strict' +import { after, afterEach, beforeEach, test } from 'node:test' +import { renderToString } from 'react-dom/server' +import { I18nextProvider } from 'react-i18next' +import { + SecureVerificationContext, + type SecureVerificationContextValue, +} from '../lib/verification-context' +import type { StartVerificationOptions } from '../types' +import { useApiTokenVerification } from './use-api-token-verification' + +type VerifyCall = ReturnType +const dom = new JSDOM('', { + url: 'http://localhost/', +}) +const i18n = createInstance() +const previousGlobals = new Map() +for (const [key, value] of Object.entries({ + window: dom.window, + document: dom.window.document, + navigator: dom.window.navigator, + IS_REACT_ACT_ENVIRONMENT: true, +})) { + previousGlobals.set(key, Object.getOwnPropertyDescriptor(globalThis, key)) + Object.defineProperty(globalThis, key, { + configurable: true, + writable: true, + value, + }) +} +await i18n.init({ lng: 'en', resources: { en: { translation: {} } } }) +const { createRoot } = await import('react-dom/client') +let container: HTMLDivElement +let root: Root + +function GateProbe(props: { capture: (call: VerifyCall) => void }) { + const verifyCall = useApiTokenVerification() + const capture = props.capture + useEffect(() => capture(verifyCall), [capture, verifyCall]) + return null +} + +beforeEach((): void => { + container = dom.window.document.createElement('div') + dom.window.document.body.append(container) + root = createRoot(container) +}) +afterEach(async (): Promise => { + await act(async (): Promise => root.unmount()) + container.remove() +}) +after((): void => { + for (const [key, descriptor] of [...previousGlobals].reverse()) { + if (descriptor) Object.defineProperty(globalThis, key, descriptor) + else Reflect.deleteProperty(globalThis, key) + } + dom.window.close() +}) + +async function renderGate( + value: SecureVerificationContextValue, + capture: (call: VerifyCall) => void +): Promise { + await act(async (): Promise => { + root.render( + + + + + + ) + }) +} + +test('requires the verification provider instead of silently bypassing the gate', (): void => { + assert.throws( + () => + renderToString( + + undefined} /> + + ), + /useSecureVerificationGate must be used within SecureVerificationProvider/ + ) +}) + +test('keeps callback identity for the same gate method and uses a replacement method', async (): Promise => { + const captured: VerifyCall[] = [] + const capture = (call: VerifyCall): void => { + captured.push(call) + } + const options: Array = [] + let apiCalls = 0 + const withVerification: SecureVerificationContextValue['withVerification'] = + async (apiCall, config) => { + options.push(config) + return apiCall() + } + await renderGate({ withVerification }, capture) + const first = captured.at(-1) + assert.ok(first) + await renderGate({ withVerification }, capture) + assert.equal(captured.at(-1), first) + assert.equal( + await first(async () => { + apiCalls++ + return 'allowed' + }), + 'allowed' + ) + assert.equal(apiCalls, 1) + assert.deepEqual(options, [ + { + scope: 'api_token', + title: 'Security verification', + description: + 'Confirm your identity before sending an API key to a chat client.', + }, + ]) + + const replacement: SecureVerificationContextValue['withVerification'] = + async () => null + await renderGate({ withVerification: replacement }, capture) + const next = captured.at(-1) + assert.ok(next && next !== first) + assert.equal( + await next(async () => { + apiCalls++ + return 'unexpected' + }), + null + ) + assert.equal(apiCalls, 1, 'cancellation must not run the protected API call') +}) + +test('propagates a gate rejection without retrying the protected operation', async (): Promise => { + const failure = new Error('verification rejected') + let gateCalls = 0 + let apiCalls = 0 + let verifyCall: VerifyCall | undefined + await renderGate( + { + withVerification: async () => { + gateCalls++ + throw failure + }, + }, + (call) => { + verifyCall = call + } + ) + assert.ok(verifyCall) + await assert.rejects( + verifyCall(async () => { + apiCalls++ + return 'unexpected' + }), + (error) => error === failure + ) + assert.equal(gateCalls, 1) + assert.equal(apiCalls, 0) +}) diff --git a/web/default/src/features/auth/secure-verification/index.ts b/web/default/src/features/auth/secure-verification/index.ts index 4568b0e7..5f81a51a 100644 --- a/web/default/src/features/auth/secure-verification/index.ts +++ b/web/default/src/features/auth/secure-verification/index.ts @@ -20,5 +20,7 @@ export * from './api' export * from './types' export * from './hooks/use-api-token-verification' export * from './hooks/use-secure-verification' +export * from './hooks/use-secure-verification-gate' +export * from './lib/verification-context' export * from './components/secure-verification-dialog' export * from './components/secure-verification-provider' diff --git a/web/default/src/features/auth/secure-verification/lib/verification-context.ts b/web/default/src/features/auth/secure-verification/lib/verification-context.ts new file mode 100644 index 00000000..235a5865 --- /dev/null +++ b/web/default/src/features/auth/secure-verification/lib/verification-context.ts @@ -0,0 +1,30 @@ +/* +Copyright (C) 2023-2026 MAX-API-Next + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues +*/ +import { createContext } from 'react' +import type { StartVerificationOptions } from '../types' + +export interface SecureVerificationContextValue { + withVerification: ( + apiCall: () => Promise, + config?: StartVerificationOptions + ) => Promise +} + +export const SecureVerificationContext = + createContext(null) diff --git a/web/default/src/features/smart-ops/alerts.test.tsx b/web/default/src/features/smart-ops/alerts.test.tsx index d764f06b..5d37afa2 100644 --- a/web/default/src/features/smart-ops/alerts.test.tsx +++ b/web/default/src/features/smart-ops/alerts.test.tsx @@ -1198,13 +1198,31 @@ describe('SmartOps active alerts', () => { ) try { - await waitFor(() => { + const singleZeroButton = await waitFor(() => { const screen = within(view.container) - assert.ok( - screen.getAllByRole('button', { - name: 'Confirm zero-quota settlement', - }).length > 0 - ) + const controls = new Map() + const buttons = screen.getAllByRole('button', { + name: (name, element) => { + if ( + name !== 'Confirm zero-quota settlement' && + name !== 'Review and close selected (0)' + ) + return false + if (element instanceof window.HTMLElement) { + const matches = controls.get(name) ?? [] + matches.push(element) + controls.set(name, matches) + } + return true + }, + }) + const zeroButtons = ( + controls.get('Confirm zero-quota settlement') ?? [] + ).filter((button) => buttons.includes(button)) + const reviewButtons = ( + controls.get('Review and close selected (0)') ?? [] + ).filter((button) => buttons.includes(button)) + assert.ok(zeroButtons.length > 0) assert.equal( screen .getByRole('checkbox', { @@ -1213,31 +1231,21 @@ describe('SmartOps active alerts', () => { .hasAttribute('data-disabled'), false ) - assert.equal( - screen - .getByRole('button', { - name: 'Review and close selected (0)', - }) - .hasAttribute('disabled'), - true - ) + assert.equal(reviewButtons.length, 1) + assert.equal(reviewButtons[0].hasAttribute('disabled'), true) + return zeroButtons[0] }) + await view.click(singleZeroButton) + const cancelledZeroDialog = within(document.body).getByRole('alertdialog') await view.click( - within(view.container).getAllByRole('button', { - name: 'Confirm zero-quota settlement', - })[0] - ) - await view.click( - within(document.body).getByRole('button', { name: 'Cancel' }) + within(cancelledZeroDialog).getByRole('button', { name: 'Cancel' }) ) assert.deepEqual(writes, []) + assert.ok(singleZeroButton.isConnected) + await view.click(singleZeroButton) + const singleZeroDialog = within(document.body).getByRole('alertdialog') await view.click( - within(view.container).getAllByRole('button', { - name: 'Confirm zero-quota settlement', - })[0] - ) - await view.click( - within(document.body).getByRole('button', { + within(singleZeroDialog).getByRole('button', { name: 'Apply zero-quota settlements', }) ) @@ -1266,8 +1274,9 @@ describe('SmartOps active alerts', () => { }) assert.equal(zeroBatchButton.hasAttribute('disabled'), false) await view.click(zeroBatchButton) + const batchZeroDialog = within(document.body).getByRole('alertdialog') await view.click( - within(document.body).getByRole('button', { + within(batchZeroDialog).getByRole('button', { name: 'Apply zero-quota settlements', }) ) @@ -1286,20 +1295,16 @@ describe('SmartOps active alerts', () => { ) }) assert.equal(within(document.body).queryByRole('dialog'), null) - assert.ok( - within(view.container).getAllByRole('button', { - name: 'Enter exact quota', - }).length > 0 - ) + const exactButtons = within(view.container).getAllByRole('button', { + name: 'Enter exact quota', + }) + assert.ok(exactButtons.length > 0) // The H3 zero-quota shortcut must not hide the exact settlement path. // Open the real dialog and verify that the exact input remains available // instead of being replaced by the zero-quota batch action. - await view.click( - within(view.container).getAllByRole('button', { - name: 'Enter exact quota', - })[0] - ) + await view.click(exactButtons[0]) + const exactDialog = within(document.body).getByRole('dialog') const exactQuotaInput = document.getElementById( 'manual-task-actual-quota' ) as HTMLInputElement @@ -1310,7 +1315,7 @@ describe('SmartOps active alerts', () => { assert.equal(exactQuotaInput.max, '100') assert.equal( ( - within(document.body).getByRole('button', { + within(exactDialog).getByRole('button', { name: 'Apply exact settlement', }) as HTMLButtonElement ).disabled, @@ -1318,7 +1323,7 @@ describe('SmartOps active alerts', () => { ) await view.click( - within(document.body).getByRole('button', { name: 'Cancel' }) + within(exactDialog).getByRole('button', { name: 'Cancel' }) ) // The exact dialog was opened from the manual-task rows above. After it // closes, select only the ordinary alert to verify that manual task diff --git a/web/default/src/features/system-settings/components/settings-page-context.tsx b/web/default/src/features/system-settings/components/settings-page-context.tsx index 3d9133da..1ceeb60e 100644 --- a/web/default/src/features/system-settings/components/settings-page-context.tsx +++ b/web/default/src/features/system-settings/components/settings-page-context.tsx @@ -17,7 +17,6 @@ along with this program. If not, see . For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues */ import { - createContext, useContext, type ComponentProps, type ReactNode, @@ -27,18 +26,7 @@ import { RotateCcw, Save } from 'lucide-react' import { createPortal } from 'react-dom' import { useTranslation } from 'react-i18next' import { Button } from '@/components/ui/button' - -type SettingsPageContextValue = { - actionsContainer: HTMLDivElement | null - titleStatusContainer: HTMLSpanElement | null - suppressSectionHeader: boolean -} - -const SettingsPageContext = createContext({ - actionsContainer: null, - titleStatusContainer: null, - suppressSectionHeader: false, -}) +import { SettingsPageContext } from '../lib/settings-page-context' type SettingsPageProviderProps = { actionsContainer: HTMLDivElement | null @@ -61,10 +49,6 @@ export function SettingsPageProvider(props: SettingsPageProviderProps) { ) } -export function useSuppressSettingsSectionHeader() { - return useContext(SettingsPageContext).suppressSectionHeader -} - type SettingsPageTitleStatusPortalProps = { children: ReactNode } diff --git a/web/default/src/features/system-settings/components/settings-section.tsx b/web/default/src/features/system-settings/components/settings-section.tsx index c4e1f5c4..6a149c49 100644 --- a/web/default/src/features/system-settings/components/settings-section.tsx +++ b/web/default/src/features/system-settings/components/settings-section.tsx @@ -17,7 +17,7 @@ along with this program. If not, see . For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues */ import { cn } from '@/lib/utils' -import { useSuppressSettingsSectionHeader } from './settings-page-context' +import { useSuppressSettingsSectionHeader } from '../hooks/use-suppress-settings-section-header' type SettingsSectionProps = { title: string diff --git a/web/default/src/features/system-settings/hooks/use-suppress-settings-section-header.ts b/web/default/src/features/system-settings/hooks/use-suppress-settings-section-header.ts new file mode 100644 index 00000000..15e63094 --- /dev/null +++ b/web/default/src/features/system-settings/hooks/use-suppress-settings-section-header.ts @@ -0,0 +1,24 @@ +/* +Copyright (C) 2023-2026 MAX-API-Next + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues +*/ +import { useContext } from 'react' +import { SettingsPageContext } from '../lib/settings-page-context' + +export function useSuppressSettingsSectionHeader(): boolean { + return useContext(SettingsPageContext).suppressSectionHeader +} diff --git a/web/default/src/features/system-settings/lib/settings-page-context.ts b/web/default/src/features/system-settings/lib/settings-page-context.ts new file mode 100644 index 00000000..4e64a817 --- /dev/null +++ b/web/default/src/features/system-settings/lib/settings-page-context.ts @@ -0,0 +1,31 @@ +/* +Copyright (C) 2023-2026 MAX-API-Next + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues +*/ +import { createContext } from 'react' + +export type SettingsPageContextValue = { + actionsContainer: HTMLDivElement | null + titleStatusContainer: HTMLSpanElement | null + suppressSectionHeader: boolean +} + +export const SettingsPageContext = createContext({ + actionsContainer: null, + titleStatusContainer: null, + suppressSectionHeader: false, +}) diff --git a/web/default/src/features/system-settings/models/tiered-pricing-editor.runtime.test.tsx b/web/default/src/features/system-settings/models/tiered-pricing-editor.runtime.test.tsx index a840f383..4c0a60ee 100644 --- a/web/default/src/features/system-settings/models/tiered-pricing-editor.runtime.test.tsx +++ b/web/default/src/features/system-settings/models/tiered-pricing-editor.runtime.test.tsx @@ -123,6 +123,31 @@ function getInputValueByLabel(container: HTMLElement, labelText: string) { return input.value } +function getInputsByLabels( + container: HTMLElement, + labels: readonly string[] +): Map { + const expected = new Set(labels) + const matches = new Map>() + const returned = new Set( + within(container).getAllByLabelText((name, element) => { + if (!expected.has(name)) return false + if (element instanceof dom.window.HTMLInputElement) { + const fields = matches.get(name) ?? new Set() + fields.add(element) + matches.set(name, fields) + } + return true + }) + ) + return new Map( + labels.map((label): [string, HTMLInputElement[]] => [ + label, + [...(matches.get(label) ?? [])].filter((field) => returned.has(field)), + ]) + ) +} + const modelA: ModelRatioData = { name: 'model-a', billingMode: 'tiered_expr', @@ -531,9 +556,14 @@ describe('optional zero prices in the actual editor', () => { /> ) } + let modePicker: HTMLElement | undefined const selectMode = async (name: string): Promise => { + assert.ok( + modePicker?.isConnected, + 'editor mode control must stay mounted' + ) await act(async () => { - fireEvent.keyDown(within(container).getAllByRole('combobox')[0], { + fireEvent.keyDown(modePicker!, { key: 'ArrowDown', }) }) @@ -554,6 +584,9 @@ describe('optional zero prices in the actual editor', () => { ) ) + modePicker = within(container).getByRole('combobox', { + name: 'Editor mode', + }) assert.equal(saved, expected) if (supported) await selectMode('Expression editor') await selectMode('Visual editor') @@ -742,8 +775,12 @@ describe('optional zero prices in the actual editor', () => { 'len < 200000 ? tier("base", p * 3 + c * 15) : tier("tier_2", p * 3 + c * 15)' ) } + const fields = getInputsByLabels( + container, + BILLING_EXTRA_VARS.map((variable) => variable.label) + ) for (const variable of BILLING_EXTRA_VARS) { - const inputs = within(container).getAllByLabelText(variable.label) + const inputs = fields.get(variable.label) ?? [] assert.equal(inputs.length, 2) for (const input of inputs) { assert.equal( diff --git a/web/default/src/features/system-settings/models/tiered-pricing-editor.tsx b/web/default/src/features/system-settings/models/tiered-pricing-editor.tsx index a8efb0ac..af377728 100644 --- a/web/default/src/features/system-settings/models/tiered-pricing-editor.tsx +++ b/web/default/src/features/system-settings/models/tiered-pricing-editor.tsx @@ -285,7 +285,11 @@ const TieredPricingEditorContent = memo(function TieredPricingEditorContent({ value={editorMode} onValueChange={(value) => handleModeChange(value as EditorMode)} > - + diff --git a/web/default/src/features/system-settings/models/tool-price-settings.tsx b/web/default/src/features/system-settings/models/tool-price-settings.tsx index 3363b788..d097e754 100644 --- a/web/default/src/features/system-settings/models/tool-price-settings.tsx +++ b/web/default/src/features/system-settings/models/tool-price-settings.tsx @@ -109,7 +109,6 @@ export const ToolPriceSettings = memo(function ToolPriceSettings({ useEffect(() => { const prices = parseInitialPrices(defaultValue) const initialRows = objectToRows(prices) - // eslint-disable-next-line react-hooks/set-state-in-effect setRows(initialRows) setJsonText(JSON.stringify(prices, null, 2)) setJsonError('') diff --git a/web/default/src/features/usage-logs/components/common-logs-filter-bar.tsx b/web/default/src/features/usage-logs/components/common-logs-filter-bar.tsx index b0875e63..3918f837 100644 --- a/web/default/src/features/usage-logs/components/common-logs-filter-bar.tsx +++ b/web/default/src/features/usage-logs/components/common-logs-filter-bar.tsx @@ -46,6 +46,7 @@ import { QUOTA_FILTER_ALL_VALUE, } from '../constants' import { buildSearchParams } from '../lib/filter' +import { isQuotaFilterValue, type QuotaFilterValue } from '../lib/quota-filter' import { getDefaultTimeRange } from '../lib/utils' import type { CommonLogFilters } from '../types' import { CommonLogsStats } from './common-logs-stats' @@ -55,11 +56,7 @@ import { LogsFilterInput, LogsFilterToolbar, } from './logs-filter-toolbar' -import { - isQuotaFilterValue, - QuotaFilterSelect, - type QuotaFilterValue, -} from './quota-filter-select' +import { QuotaFilterSelect } from './quota-filter-select' import { useUsageLogsContext } from './usage-logs-provider' const route = getRouteApi('/_authenticated/usage-logs/$section') diff --git a/web/default/src/features/usage-logs/components/dialogs/user-info-dialog.runtime.test.tsx b/web/default/src/features/usage-logs/components/dialogs/user-info-dialog.runtime.test.tsx new file mode 100644 index 00000000..e08a4bd9 --- /dev/null +++ b/web/default/src/features/usage-logs/components/dialogs/user-info-dialog.runtime.test.tsx @@ -0,0 +1,173 @@ +/* +Copyright (C) 2023-2026 MAX-API-Next + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues +*/ +import { act, type ComponentProps } from 'react' +import type { Root } from 'react-dom/client' +import type { AxiosResponse, InternalAxiosRequestConfig } from 'axios' +import { createInstance } from 'i18next' +import { JSDOM } from 'jsdom' +import assert from 'node:assert/strict' +import { after, afterEach, beforeEach, test } from 'node:test' +import { I18nextProvider } from 'react-i18next' +import type { UserInfo } from '../../types' + +let createRoot: typeof import('react-dom/client').createRoot +let within: typeof import('@testing-library/react/pure').within +let UserInfoDialog: typeof import('./user-info-dialog').UserInfoDialog +let api: typeof import('@/lib/api').api +let originalAdapter: typeof api.defaults.adapter +const dom = new JSDOM('', { + url: 'http://localhost/', + pretendToBeVisual: true, +}) +const i18n = createInstance() +const previousGlobals = new Map() +const requests: InternalAxiosRequestConfig[] = [] +const user: UserInfo = { + id: 7, + username: 'lint-user', + display_name: 'Lint User', + quota: 1000000, + used_quota: 0, + request_count: 0, + group: 'default', + aff_code: 'synthetic-invitation', + aff_count: 0, + aff_quota: 0, +} +let container: HTMLDivElement +let root: Root + +async function setup(): Promise { + const window = dom.window + const globals: Record = { + window, + document: window.document, + navigator: window.navigator, + HTMLElement: window.HTMLElement, + Element: window.Element, + Node: window.Node, + Event: window.Event, + MouseEvent: window.MouseEvent, + MutationObserver: window.MutationObserver, + getComputedStyle: window.getComputedStyle.bind(window), + requestAnimationFrame: window.requestAnimationFrame.bind(window), + cancelAnimationFrame: window.cancelAnimationFrame.bind(window), + localStorage: window.localStorage, + IS_REACT_ACT_ENVIRONMENT: true, + } + for (const [key, value] of Object.entries(globals)) { + previousGlobals.set(key, Object.getOwnPropertyDescriptor(globalThis, key)) + Object.defineProperty(globalThis, key, { + configurable: true, + writable: true, + value, + }) + } + await i18n.init({ + lng: 'en', + fallbackLng: 'en', + resources: { en: { translation: {} } }, + }) + ;({ createRoot } = await import('react-dom/client')) + ;({ within } = await import('@testing-library/react/pure')) + ;({ UserInfoDialog } = await import('./user-info-dialog')) + ;({ api } = await import('@/lib/api')) + originalAdapter = api.defaults.adapter +} + +await setup() + +beforeEach((): void => { + container = dom.window.document.createElement('div') + dom.window.document.body.append(container) + root = createRoot(container) + requests.length = 0 + api.defaults.adapter = async ( + config: InternalAxiosRequestConfig + ): Promise => { + requests.push(config) + assert.equal(config.url, '/api/user/7') + assert.equal(config.method, 'get') + return { + config, + data: { success: true, data: user }, + status: 200, + statusText: 'OK', + headers: {}, + } + } +}) + +afterEach(async (): Promise => { + await act(async (): Promise => root.unmount()) + container.remove() + api.defaults.adapter = originalAdapter +}) + +after((): void => { + for (const [key, descriptor] of [...previousGlobals].reverse()) { + if (descriptor) Object.defineProperty(globalThis, key, descriptor) + else Reflect.deleteProperty(globalThis, key) + } + dom.window.close() +}) + +async function renderDialog( + onOpenChange: ComponentProps['onOpenChange'] +): Promise { + await act(async (): Promise => { + root.render( + + + + ) + }) +} + +test('preserves selected user information across unrelated parent updates', async (): Promise => { + await renderDialog(() => undefined) + const screen = within(dom.window.document.body) + const username = screen.getByText('lint-user') + const range = dom.window.document.createRange() + range.selectNodeContents(username) + const selection = dom.window.getSelection() + assert.ok(selection) + selection.removeAllRanges() + selection.addRange(range) + assert.equal(selection.toString(), 'lint-user') + + await renderDialog(() => undefined) + + assert.equal(requests.length, 1, 'parent updates must not refetch the user') + assert.ok( + screen.getByText('lint-user') === username, + 'user fields must stay mounted' + ) + assert.equal( + selection.toString(), + 'lint-user', + 'selected text must stay selected' + ) + assert.ok(screen.getByText('Lint User')) + assert.ok(screen.getByText('synthetic-invitation')) + assert.equal( + screen.getByText('Invited Users').nextElementSibling?.textContent, + '0' + ) +}) diff --git a/web/default/src/features/usage-logs/components/dialogs/user-info-dialog.tsx b/web/default/src/features/usage-logs/components/dialogs/user-info-dialog.tsx index 68d10bbb..5d8cadc8 100644 --- a/web/default/src/features/usage-logs/components/dialogs/user-info-dialog.tsx +++ b/web/default/src/features/usage-logs/components/dialogs/user-info-dialog.tsx @@ -38,6 +38,20 @@ interface UserInfoDialogProps { onOpenChange: (open: boolean) => void } +type InfoItemProps = { + label: string + value: string | number +} + +function InfoItem(props: InfoItemProps) { + return ( +
+ +
{props.value}
+
+ ) +} + export function UserInfoDialog({ userId, open, @@ -74,19 +88,6 @@ export function UserInfoDialog({ } }, [open, userId, fetchUserInfo]) - const InfoItem = ({ - label, - value, - }: { - label: string - value: string | number - }) => ( -
- -
{value}
-
- ) - return ( diff --git a/web/default/src/features/usage-logs/components/quota-filter-select.tsx b/web/default/src/features/usage-logs/components/quota-filter-select.tsx index e79c93b7..f10bb62e 100644 --- a/web/default/src/features/usage-logs/components/quota-filter-select.tsx +++ b/web/default/src/features/usage-logs/components/quota-filter-select.tsx @@ -26,17 +26,8 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select' -import { - QUOTA_FILTER_ALL_VALUE, - QUOTA_FILTER_VALUES, - QUOTA_FILTERS, -} from '../constants' - -export type QuotaFilterValue = (typeof QUOTA_FILTER_VALUES)[number] - -export function isQuotaFilterValue(value: string): value is QuotaFilterValue { - return (QUOTA_FILTER_VALUES as readonly string[]).includes(value) -} +import { QUOTA_FILTER_ALL_VALUE, QUOTA_FILTERS } from '../constants' +import { isQuotaFilterValue, type QuotaFilterValue } from '../lib/quota-filter' interface QuotaFilterSelectProps { value: QuotaFilterValue diff --git a/web/default/src/features/usage-logs/components/task-logs-filter-bar.tsx b/web/default/src/features/usage-logs/components/task-logs-filter-bar.tsx index ad81ca67..b7179b38 100644 --- a/web/default/src/features/usage-logs/components/task-logs-filter-bar.tsx +++ b/web/default/src/features/usage-logs/components/task-logs-filter-bar.tsx @@ -24,6 +24,7 @@ import { useTranslation } from 'react-i18next' import { useIsAdmin } from '@/hooks/use-admin' import { QUOTA_FILTER_ALL_VALUE } from '../constants' import { buildSearchParams } from '../lib/filter' +import { isQuotaFilterValue, type QuotaFilterValue } from '../lib/quota-filter' import { getDefaultTimeRange } from '../lib/utils' import type { DrawingLogFilters, LogCategory, TaskLogFilters } from '../types' import { CompactDateTimeRangePicker } from './compact-date-time-range-picker' @@ -32,11 +33,7 @@ import { LogsFilterInput, LogsFilterToolbar, } from './logs-filter-toolbar' -import { - isQuotaFilterValue, - QuotaFilterSelect, - type QuotaFilterValue, -} from './quota-filter-select' +import { QuotaFilterSelect } from './quota-filter-select' const route = getRouteApi('/_authenticated/usage-logs/$section') diff --git a/web/default/src/features/usage-logs/lib/quota-filter.ts b/web/default/src/features/usage-logs/lib/quota-filter.ts new file mode 100644 index 00000000..03a3e325 --- /dev/null +++ b/web/default/src/features/usage-logs/lib/quota-filter.ts @@ -0,0 +1,25 @@ +/* +Copyright (C) 2023-2026 MAX-API-Next + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues +*/ +import { QUOTA_FILTER_VALUES } from '../constants' + +export type QuotaFilterValue = (typeof QUOTA_FILTER_VALUES)[number] + +export function isQuotaFilterValue(value: string): value is QuotaFilterValue { + return (QUOTA_FILTER_VALUES as readonly string[]).includes(value) +} diff --git a/web/default/tests/secure-verification-continuation.test.tsx b/web/default/tests/secure-verification-continuation.test.tsx index 69134544..9fb93c57 100644 --- a/web/default/tests/secure-verification-continuation.test.tsx +++ b/web/default/tests/secure-verification-continuation.test.tsx @@ -104,7 +104,7 @@ mock.module('../src/features/auth/secure-verification/api', () => ({ })) mock.module( - '../src/features/auth/secure-verification/components/secure-verification-provider', + '../src/features/auth/secure-verification/hooks/use-secure-verification-gate', () => ({ useSecureVerificationGate: () => ({ withVerification: gateWithVerification,