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
- }) => (
-
- )
-
return (