diff --git a/relay/channel/openai/first_result_test.go b/relay/channel/openai/first_result_test.go index 7e3224f7..9833117e 100644 --- a/relay/channel/openai/first_result_test.go +++ b/relay/channel/openai/first_result_test.go @@ -1,6 +1,7 @@ package openai import ( + "context" "io" "net/http" "strings" @@ -16,13 +17,14 @@ import ( "github.com/stretchr/testify/require" ) -// Use the real adapters and keep the provider open after metadata. The close -// is a bounded fallback: a broken watchdog must fail rather than hang the suite. +// Keep the provider open after metadata until cleanup. The request deadline +// bounds a broken watchdog without racing it against a fixture-generated EOF. func stalledFirstResultStream(t *testing.T, frames []string) *http.Response { t.Helper() r, w := io.Pipe() finished := make(chan struct{}) - t.Cleanup(func() { _ = r.Close(); _ = w.Close(); <-finished }) + release := make(chan struct{}) + t.Cleanup(func() { close(release); _ = r.Close(); _ = w.Close(); <-finished }) go func() { defer close(finished) defer w.Close() @@ -31,7 +33,7 @@ func stalledFirstResultStream(t *testing.T, frames []string) *http.Response { return } } - time.Sleep(1500 * time.Millisecond) + <-release }() return &http.Response{StatusCode: http.StatusOK, Body: r} } @@ -57,6 +59,9 @@ func TestAdaptersMetadataDoesNotEndFirstResultWait(t *testing.T) { *operation_setting.GetMonitorSetting() = operation_setting.MonitorSetting{StreamingFirstResultTimeoutSeconds: 1} t.Cleanup(func() { *operation_setting.GetMonitorSetting() = oldSetting }) c, recorder, info := newChatStreamTest(t) + ctx, cancel := context.WithTimeout(c.Request.Context(), 3*time.Second) + defer cancel() + c.Request = c.Request.WithContext(ctx) info.InitChannelMeta(c) info.UpstreamModelName = "gpt-test" common.EmptyCompletionRetryEnabled = test.buffer diff --git a/relay/helper/stream_scanner_test.go b/relay/helper/stream_scanner_test.go index 2b49753b..69896079 100644 --- a/relay/helper/stream_scanner_test.go +++ b/relay/helper/stream_scanner_test.go @@ -302,58 +302,91 @@ func TestStreamScannerHandler_ScannerDecoupledFromSlowHandler(t *testing.T) { t.Parallel() const numChunks = 50 - const upstreamDelay = 10 * time.Millisecond - const handlerDelay = 20 * time.Millisecond - pr, pw := io.Pipe() - var upstreamBusy atomic.Int64 + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + handlerEntered := make(chan struct{}) + releaseHandler := make(chan struct{}) + readAhead := make(chan struct{}) + producerDone := make(chan struct{}) + done := make(chan struct{}) + var releaseOnce sync.Once + t.Cleanup(func() { + cancel() + releaseOnce.Do(func() { close(releaseHandler) }) + _ = pr.Close() + _ = pw.Close() + for _, worker := range []<-chan struct{}{producerDone, done} { + select { + case <-worker: + case <-time.After(time.Second): + t.Error("stream test worker did not stop after cancellation") + } + } + }) go func() { + defer close(producerDone) defer pw.Close() - feedStart := time.Now() for i := 0; i < numChunks; i++ { - fmt.Fprintf(pw, "data: {\"id\":%d}\n", i) - time.Sleep(upstreamDelay) + if _, err := fmt.Fprintf(pw, "data: {\"id\":%d}\n", i); err != nil { + return + } + if i == 1 { + close(readAhead) + } } fmt.Fprint(pw, "data: [DONE]\n") - upstreamBusy.Store(int64(time.Since(feedStart))) }() recorder := httptest.NewRecorder() c, _ := gin.CreateTestContext(recorder) - c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil).WithContext(ctx) resp := &http.Response{Body: pr} info := &relaycommon.RelayInfo{ChannelMeta: &relaycommon.ChannelMeta{}} var count atomic.Int64 - var handlerBusy atomic.Int64 - start := time.Now() - done := make(chan struct{}) + var received []string go func() { + defer close(done) StreamScannerHandler(c, resp, info, func(data string, sr *StreamResult) { - chunkStart := time.Now() - time.Sleep(handlerDelay) - handlerBusy.Add(int64(time.Since(chunkStart))) - count.Add(1) + if count.Add(1) == 1 { + close(handlerEntered) + select { + case <-releaseHandler: + case <-ctx.Done(): + return + } + } + received = append(received, data) }) - close(done) }() + // A coupled scanner cannot read the second pipe write while its first + // callback is blocked. Synchronize that contract without sleep-speed ratios. + for _, phase := range []struct { + name string + done <-chan struct{} + }{{"first handler", handlerEntered}, {"scanner read-ahead", readAhead}} { + select { + case <-phase.done: + case <-ctx.Done(): + t.Fatalf("timed out waiting for %s", phase.name) + } + } + assert.Equal(t, int64(1), count.Load(), "only the blocked first callback can run") + releaseOnce.Do(func() { close(releaseHandler) }) select { case <-done: - case <-time.After(15 * time.Second): + case <-ctx.Done(): t.Fatal("StreamScannerHandler did not complete in time") } - - elapsed := time.Since(start) assert.Equal(t, int64(numChunks), count.Load()) - - // 用实测耗时估算"耦合"基线,避免系统繁忙时 sleep 超时偏移导致名义值失真 - coupledTime := time.Duration(upstreamBusy.Load() + handlerBusy.Load()) - t.Logf("elapsed=%v, coupled_estimate=%v", elapsed, coupledTime) - - assert.Less(t, elapsed, coupledTime*85/100, - "decoupled elapsed time (%v) should be significantly less than coupled estimate (%v)", elapsed, coupledTime) + assert.Equal(t, numChunks, info.ReceivedResponseCount) + require.Len(t, received, numChunks) + for i, data := range received { + assert.Equal(t, fmt.Sprintf("{\"id\":%d}", i), data) + } + assert.Equal(t, relaycommon.StreamEndReasonDone, info.StreamStatus.EndReason) } func TestStreamScannerHandler_SlowUpstreamFastHandler(t *testing.T) { diff --git a/relay/responses_first_result_settlement_test.go b/relay/responses_first_result_settlement_test.go index 132ceb33..0e8ec267 100644 --- a/relay/responses_first_result_settlement_test.go +++ b/relay/responses_first_result_settlement_test.go @@ -135,7 +135,9 @@ func TestResponsesFirstResultTimeoutSettlesDeliveredWhitespace(t *testing.T) { case <-r.Context().Done(): upstreamCanceled <- true case <-time.After(3 * time.Second): - upstreamCanceled <- false + // A delayed select may see both channels ready. Confirm + // the request state before reporting a missing cancellation. + upstreamCanceled <- r.Context().Err() != nil } })) t.Cleanup(upstream.Close) diff --git a/web/default/scripts/run-tests.mjs b/web/default/scripts/run-tests.mjs index e2cbdb71..579060b0 100644 --- a/web/default/scripts/run-tests.mjs +++ b/web/default/scripts/run-tests.mjs @@ -274,6 +274,7 @@ function runTestFiles(files, label, bunArguments) { if (files.length === 0) return 'skipped' console.log(`\n==> ${label} (${files.length} file${files.length === 1 ? '' : 's'})`) + const startedAt = performance.now() const result = Bun.spawnSync({ cmd: buildTestCommand(files, bunArguments), cwd: projectRoot, @@ -281,6 +282,14 @@ function runTestFiles(files, label, bunArguments) { stdout: 'pipe', stderr: 'pipe', }) + console.log( + `MAX_API_TEST_STAGE ${JSON.stringify({ + label, + fileCount: files.length, + exitCode: result.exitCode, + wallMilliseconds: Math.round(performance.now() - startedAt), + })}` + ) writeChildOutput(result) if (result.exitCode === 0) return 'passed' diff --git a/web/default/src/components/ai-elements/web-preview.tsx b/web/default/src/components/ai-elements/web-preview.tsx index 49558b37..7ed95ce9 100644 --- a/web/default/src/components/ai-elements/web-preview.tsx +++ b/web/default/src/components/ai-elements/web-preview.tsx @@ -23,13 +23,13 @@ import { createContext, type ReactNode, useContext, - useEffect, useState, } from 'react' import { ChevronDownIcon } from 'lucide-react' import { useTranslation } from 'react-i18next' import dayjs from '@/lib/dayjs' import { cn } from '@/lib/utils' +import { useResettableState } from '@/hooks/use-resettable-state' import { Button } from '@/components/ui/button' import { Collapsible, @@ -162,12 +162,7 @@ export const WebPreviewUrl = ({ }: WebPreviewUrlProps) => { const { t } = useTranslation() const { url, setUrl } = useWebPreview() - const [inputValue, setInputValue] = useState(url) - - // Sync input value with context URL when it changes externally - useEffect(() => { - setInputValue(url) - }, [url]) + const [inputValue, setInputValue] = useResettableState(url, url) const handleChange = (event: React.ChangeEvent) => { setInputValue(event.target.value) 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/components/risk-acknowledgement-dialog.tsx b/web/default/src/components/risk-acknowledgement-dialog.tsx index 03eaa846..b34fa241 100644 --- a/web/default/src/components/risk-acknowledgement-dialog.tsx +++ b/web/default/src/components/risk-acknowledgement-dialog.tsx @@ -16,9 +16,10 @@ along with this program. If not, see . For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues */ -import { useEffect, useMemo, useState } from 'react' +import { useMemo } from 'react' import { useTranslation } from 'react-i18next' import { cn } from '@/lib/utils' +import { useResettableState } from '@/hooks/use-resettable-state' import { AlertDialog, AlertDialogCancel, @@ -83,22 +84,21 @@ export function RiskAcknowledgementDialog({ className, }: RiskAcknowledgementDialogProps) { const { t } = useTranslation() - const [checkedItems, setCheckedItems] = useState([]) - const [typedText, setTypedText] = useState('') - const [typedTextParts, setTypedTextParts] = useState([]) const normalizedRequiredTextParts = useMemo< NormalizedRequiredTextPart[] >(() => { let inputIndex = 0 - return requiredTextParts.map((part) => { + const parts: NormalizedRequiredTextPart[] = [] + for (const part of requiredTextParts) { if (part.type === 'input') { - const normalizedPart = { ...part, inputIndex } + parts.push({ ...part, inputIndex }) inputIndex += 1 - return normalizedPart + } else { + parts.push(part) } - return part - }) + } + return parts }, [requiredTextParts]) const requiredTextInputCount = useMemo( @@ -112,12 +112,16 @@ export function RiskAcknowledgementDialog({ ? normalizedRequiredTextParts.map((part) => part.text).join('') : requiredText - useEffect(() => { - if (!open) return - setCheckedItems(Array(checklist.length).fill(false)) - setTypedText('') - setTypedTextParts(Array(requiredTextInputCount).fill('')) - }, [open, checklist.length, requiredTextInputCount]) + const draftKey = `${open}:${checklist.length}:${requiredTextInputCount}` + const [checkedItems, setCheckedItems] = useResettableState( + () => Array(checklist.length).fill(false), + draftKey + ) + const [typedText, setTypedText] = useResettableState('', draftKey) + const [typedTextParts, setTypedTextParts] = useResettableState( + () => Array(requiredTextInputCount).fill(''), + draftKey + ) const allChecked = useMemo(() => { if (checklist.length === 0) return true diff --git a/web/default/src/features/auth/hooks/use-oauth-login.ts b/web/default/src/features/auth/hooks/use-oauth-login.ts index 77d3b44d..6c7944df 100644 --- a/web/default/src/features/auth/hooks/use-oauth-login.ts +++ b/web/default/src/features/auth/hooks/use-oauth-login.ts @@ -41,20 +41,21 @@ type LogoutRequestConfig = AxiosRequestConfig & { export function useOAuthLogin(status: SystemStatus | null) { const { t } = useTranslation() const [isLoading, setIsLoading] = useState(false) - const [githubButtonText, setGithubButtonText] = useState('') + const [githubButtonMessage, setGithubButtonText] = useState( + 'Continue with GitHub' + ) + const githubButtonText = t(githubButtonMessage) const [githubButtonDisabled, setGithubButtonDisabled] = useState(false) const githubTimeoutRef = useRef(null) const { auth } = useAuthStore() useEffect(() => { - setGithubButtonText(t('Continue with GitHub')) - return () => { if (githubTimeoutRef.current) { clearTimeout(githubTimeoutRef.current) } } - }, [t]) + }, []) const resetSession = async () => { try { @@ -77,7 +78,7 @@ export function useOAuthLogin(status: SystemStatus | null) { setIsLoading(true) setGithubButtonDisabled(true) - setGithubButtonText(t('Redirecting to GitHub...')) + setGithubButtonText('Redirecting to GitHub...') if (githubTimeoutRef.current) { clearTimeout(githubTimeoutRef.current) @@ -86,7 +87,7 @@ export function useOAuthLogin(status: SystemStatus | null) { githubTimeoutRef.current = setTimeout(() => { setIsLoading(false) setGithubButtonText( - t('Request timed out, please refresh and restart GitHub login') + 'Request timed out, please refresh and restart GitHub login' ) setGithubButtonDisabled(true) }, 20000) @@ -100,7 +101,7 @@ export function useOAuthLogin(status: SystemStatus | null) { clearTimeout(githubTimeoutRef.current) } setIsLoading(false) - setGithubButtonText(t('Continue with GitHub')) + setGithubButtonText('Continue with GitHub') setGithubButtonDisabled(false) return } @@ -113,7 +114,7 @@ export function useOAuthLogin(status: SystemStatus | null) { clearTimeout(githubTimeoutRef.current) } setIsLoading(false) - setGithubButtonText(t('Continue with GitHub')) + setGithubButtonText('Continue with GitHub') setGithubButtonDisabled(false) } } diff --git a/web/default/src/features/auth/lib/app-session-verifier.ts b/web/default/src/features/auth/lib/app-session-verifier.ts index d95b1430..eef9ac9f 100644 --- a/web/default/src/features/auth/lib/app-session-verifier.ts +++ b/web/default/src/features/auth/lib/app-session-verifier.ts @@ -18,12 +18,41 @@ For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API */ import { useAuthStore } from '@/stores/auth-store' import { getSelf } from '@/lib/api' +import { isStaleAuthResponse } from '@/lib/auth-errors' import { createSessionVerifier } from './session-verifier' +async function getSelfForVerification() { + while (true) { + const starting = useAuthStore.getState() + try { + const response = await getSelf() + const current = useAuthStore.getState() + // Authenticated identities include the revision below. When both are + // anonymous, the verifier cannot see a reset, so refresh this pure read. + if ( + current.sessionRevision === starting.sessionRevision || + starting.auth.user || + current.auth.user + ) + return response + } catch (error) { + const current = useAuthStore.getState() + if ( + isStaleAuthResponse(error) && + !starting.auth.user && + !current.auth.user + ) + continue + throw error + } + } +} + export const appSessionVerifier = createSessionVerifier({ getAuthState: () => useAuthStore.getState().auth, - getSelf, - getUserIdentity: (user) => user.id, + getSelf: getSelfForVerification, + getUserIdentity: (user) => + `${useAuthStore.getState().sessionRevision}:${user.id}`, isUnauthorized: (error: unknown) => (error as { response?: { status?: number } })?.response?.status === 401, }) 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/hooks/use-mobile.tsx b/web/default/src/features/auth/secure-verification/lib/verification-context.ts similarity index 57% rename from web/default/src/hooks/use-mobile.tsx rename to web/default/src/features/auth/secure-verification/lib/verification-context.ts index 55562f80..235a5865 100644 --- a/web/default/src/hooks/use-mobile.tsx +++ b/web/default/src/features/auth/secure-verification/lib/verification-context.ts @@ -16,22 +16,15 @@ along with this program. If not, see . For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues */ -import * as React from 'react' - -const MOBILE_BREAKPOINT = 768 - -export function useIsMobile() { - const [isMobile, setIsMobile] = React.useState(undefined) - - React.useEffect(() => { - const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`) - const onChange = () => { - setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) - } - mql.addEventListener('change', onChange) - setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) - return () => mql.removeEventListener('change', onChange) - }, []) - - return !!isMobile +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/channels/components/dialogs/balance-query-dialog.tsx b/web/default/src/features/channels/components/dialogs/balance-query-dialog.tsx index dda0ae0c..5edc4489 100644 --- a/web/default/src/features/channels/components/dialogs/balance-query-dialog.tsx +++ b/web/default/src/features/channels/components/dialogs/balance-query-dialog.tsx @@ -16,13 +16,15 @@ along with this program. If not, see . For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues */ -import { useEffect, useState } from 'react' +import { useCallback, useEffect, useEffectEvent } from 'react' import { useQueryClient } from '@tanstack/react-query' import { Loader2, RefreshCw, DollarSign } from 'lucide-react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import { formatCurrencyFromUSD } from '@/lib/currency' import { formatTimestampToDate } from '@/lib/format' +import { useReadScope } from '@/hooks/use-read-scope' +import { useResettableState } from '@/hooks/use-resettable-state' import { Button } from '@/components/ui/button' import { Dialog, @@ -52,48 +54,67 @@ export function BalanceQueryDialog({ const { t } = useTranslation() const { currentRow, setCurrentRow } = useChannels() const queryClient = useQueryClient() - const [isQuerying, setIsQuerying] = useState(false) - const [balance, setBalance] = useState(null) - const [balanceUpdatedTime, setBalanceUpdatedTime] = useState( - null + const channelId = currentRow?.id + const isCodex = currentRow?.type === 57 + const beginRead = useReadScope(JSON.stringify([open, channelId])) + const [isQuerying, setIsQuerying] = useResettableState( + Boolean(open && isCodex && channelId), + beginRead + ) + const [balance, setBalance] = useResettableState( + null, + beginRead ) + const [balanceUpdatedTime, setBalanceUpdatedTime] = useResettableState< + number | null + >(null, beginRead) const [codexUsageResponse, setCodexUsageResponse] = - useState(null) - - const isCodex = currentRow?.type === 57 - - const handleQueryCodexUsage = async () => { - const row = currentRow - if (!row) return + useResettableState(null, beginRead) + const loadCodexUsage = useCallback(() => { + if (!channelId) return Promise.resolve() + const current = beginRead() + if (!current()) return Promise.resolve() + return getCodexUsage(channelId) + .then((res) => { + if (!current()) return + if (!res.success) + throw new Error(res.message || t('Failed to fetch usage')) + setCodexUsageResponse(res) + }) + .catch((error: unknown) => { + if (current()) + toast.error( + error instanceof Error ? error.message : t('Failed to fetch usage') + ) + }) + .finally(() => { + if (current()) setIsQuerying(false) + }) + }, [channelId, beginRead, setCodexUsageResponse, setIsQuerying, t]) + const handleQueryCodexUsage = () => { setIsQuerying(true) - try { - const res = await getCodexUsage(row.id) - if (!res.success) { - throw new Error(res.message || t('Failed to fetch usage')) - } - setCodexUsageResponse(res) - } catch (error: unknown) { - toast.error( - error instanceof Error ? error.message : t('Failed to fetch usage') - ) - } finally { - setIsQuerying(false) - } + return loadCodexUsage() } - + const readInitialUsage = useEffectEvent(() => loadCodexUsage()) useEffect(() => { - if (!isCodex) return - if (!open) return - handleQueryCodexUsage() - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [open, isCodex]) + if (open && isCodex) void readInitialUsage() + }, [open, isCodex, channelId, beginRead]) if (!currentRow) return null const handleQueryBalance = async () => { + const current = beginRead() + if (!current()) return setIsQuerying(true) try { const response = await updateChannelBalance(currentRow.id) + if (!current()) { + if (response.success && response.balance !== undefined) + void queryClient.invalidateQueries({ + queryKey: channelsQueryKeys.lists(), + }) + return + } if (response.success && response.balance !== undefined) { const newBalance = response.balance const now = Math.floor(Date.now() / 1000) @@ -117,11 +138,12 @@ export function BalanceQueryDialog({ toast.error(response.message || t('Failed to query balance')) } } catch (error: unknown) { + if (!current()) return toast.error( error instanceof Error ? error.message : t('Failed to query balance') ) } finally { - setIsQuerying(false) + if (current()) setIsQuerying(false) } } diff --git a/web/default/src/features/channels/components/dialogs/codex-oauth-dialog.runtime.test.tsx b/web/default/src/features/channels/components/dialogs/codex-oauth-dialog.runtime.test.tsx new file mode 100644 index 00000000..a3b5c79a --- /dev/null +++ b/web/default/src/features/channels/components/dialogs/codex-oauth-dialog.runtime.test.tsx @@ -0,0 +1,119 @@ +/* +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, useState } from 'react' +import { createReactTestEnvironment } from '@/test/react' +import assert from 'node:assert/strict' +import { after, test } from 'node:test' +import { api } from '@/lib/api' + +const env = createReactTestEnvironment() +await env.setup() +const { fireEvent, within } = await import('@testing-library/react/pure') +const { CodexOAuthDialog } = await import('./codex-oauth-dialog') +after(() => env.teardown()) + +for (const transition of ['editor', 'reopen'] as const) + test('late OAuth completion is ignored after ' + transition, async () => { + const adapter = api.defaults.adapter + let release!: () => void, started!: () => void + const ready = new Promise((resolve) => { + started = resolve + }) + const assigned: string[] = [] + const requests: string[] = [] + api.defaults.adapter = async (config) => { + requests.push(config.url || '') + assert.equal(config.url, '/api/channel/codex/oauth/complete') + started() + await new Promise((resolve) => { + release = resolve + }) + return { + config, + status: 200, + statusText: 'OK', + headers: {}, + data: { + success: true, + data: { key: 'synthetic-credential-for-editor-one' }, + }, + } + } + function Harness() { + const [editor, setEditor] = useState('one') + const [open, setOpen] = useState(true) + return ( + <> + + {}} + onKeyGenerated={(key) => assigned.push(key)} + /> + + ) + } + const view = await env.render() + const body = within(env.dom.window.document.body) + try { + const input = body.getByPlaceholderText( + 'Paste the full callback URL (includes code & state)' + ) as HTMLInputElement + await act(async () => { + fireEvent.change(input, { + target: { + value: 'http://localhost/callback?code=synthetic&state=synthetic', + }, + }) + }) + const generate = body.getByRole('button', { + name: 'Generate credential', + }) as HTMLButtonElement + assert.equal(generate.disabled, false) + await view.click(generate) + await ready + await view.click(within(view.container).getByText('Change editor')) + if (transition === 'reopen') + await view.click(within(view.container).getByText('Change editor')) + await act(async () => release()) + assert.deepEqual(assigned, []) + assert.equal( + ( + body.getByPlaceholderText( + 'Paste the full callback URL (includes code & state)' + ) as HTMLInputElement + ).value, + '' + ) + assert.deepEqual(requests, ['/api/channel/codex/oauth/complete']) + } finally { + release?.() + await view.unmount() + api.defaults.adapter = adapter + } + }) diff --git a/web/default/src/features/channels/components/dialogs/codex-oauth-dialog.tsx b/web/default/src/features/channels/components/dialogs/codex-oauth-dialog.tsx index d1ea7225..a5558c6e 100644 --- a/web/default/src/features/channels/components/dialogs/codex-oauth-dialog.tsx +++ b/web/default/src/features/channels/components/dialogs/codex-oauth-dialog.tsx @@ -16,12 +16,14 @@ along with this program. If not, see . For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues */ -import { useEffect, useMemo, useState } from 'react' +import { useLayoutEffect, useMemo, useRef } from 'react' import { ExternalLink, Copy, Check, Loader2 } from 'lucide-react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' +import { useAuthStore } from '@/stores/auth-store' import { tryPrettyJson } from '@/lib/utils' import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard' +import { useResettableState } from '@/hooks/use-resettable-state' import { Alert, AlertDescription } from '@/components/ui/alert' import { Button } from '@/components/ui/button' import { @@ -39,33 +41,36 @@ type CodexOAuthDialogProps = { open: boolean onOpenChange: (open: boolean) => void onKeyGenerated: (key: string) => void + scopeKey?: string } export function CodexOAuthDialog({ open, onOpenChange, onKeyGenerated, + scopeKey, }: CodexOAuthDialogProps) { const { t } = useTranslation() const { copiedText, copyToClipboard } = useCopyToClipboard({ notify: false }) - const [state, setState] = useState({ - authorizeUrl: '', - callbackUrl: '', - isStarting: false, - isCompleting: false, - }) - - useEffect(() => { - if (!open) { - setState({ - authorizeUrl: '', - callbackUrl: '', - isStarting: false, - isCompleting: false, - }) + const sessionRevision = useAuthStore((state) => state.sessionRevision) + const draftKey = JSON.stringify([open, scopeKey, sessionRevision]) + const lifecycle = useRef(0) + useLayoutEffect(() => { + lifecycle.current += 1 + return () => { + lifecycle.current += 1 } - }, [open]) + }, [draftKey]) + const [state, setState] = useResettableState( + () => ({ + authorizeUrl: '', + callbackUrl: '', + isStarting: false, + isCompleting: false, + }), + draftKey + ) const canCopyAuthorizeUrl = Boolean(state.authorizeUrl && !state.isStarting) const canComplete = useMemo( @@ -74,9 +79,15 @@ export function CodexOAuthDialog({ ) const handleStart = async () => { + if (!open) return + const generation = lifecycle.current + const current = () => + lifecycle.current === generation && + useAuthStore.getState().sessionRevision === sessionRevision setState((prev) => ({ ...prev, isStarting: true })) try { const res = await startCodexOAuth() + if (!current()) return if (!res.success) { throw new Error(res.message || 'Failed to start OAuth') } @@ -90,25 +101,31 @@ export function CodexOAuthDialog({ try { window.open(url, '_blank', 'noopener,noreferrer') toast.success(t('Opened authorization page')) - } catch (error) { + } catch { // eslint-disable-next-line no-console - console.warn('Failed to open authorization page:', error) + console.warn('Failed to open authorization page') toast.warning(t('Please manually copy and open the authorization link')) } } catch (error) { + if (!current()) return toast.error( error instanceof Error ? error.message : t('OAuth start failed') ) } finally { - setState((prev) => ({ ...prev, isStarting: false })) + if (current()) setState((prev) => ({ ...prev, isStarting: false })) } } const handleComplete = async () => { - if (!state.callbackUrl.trim()) return + if (!open || !state.callbackUrl.trim()) return + const generation = lifecycle.current + const current = () => + lifecycle.current === generation && + useAuthStore.getState().sessionRevision === sessionRevision setState((prev) => ({ ...prev, isCompleting: true })) try { const res = await completeCodexOAuth(state.callbackUrl.trim()) + if (!current()) return if (!res.success) { throw new Error(res.message || 'OAuth failed') } @@ -122,9 +139,10 @@ export function CodexOAuthDialog({ toast.success(t('Credential generated')) onOpenChange(false) } catch (error) { + if (!current()) return toast.error(error instanceof Error ? error.message : t('OAuth failed')) } finally { - setState((prev) => ({ ...prev, isCompleting: false })) + if (current()) setState((prev) => ({ ...prev, isCompleting: false })) } } diff --git a/web/default/src/features/channels/components/dialogs/edit-tag-dialog.tsx b/web/default/src/features/channels/components/dialogs/edit-tag-dialog.tsx index b73c8e8c..6c0f6eda 100644 --- a/web/default/src/features/channels/components/dialogs/edit-tag-dialog.tsx +++ b/web/default/src/features/channels/components/dialogs/edit-tag-dialog.tsx @@ -16,11 +16,12 @@ along with this program. If not, see . For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues */ -import { useEffect, useState } from 'react' +import { useState } from 'react' import { useQuery, useQueryClient } from '@tanstack/react-query' import { Loader2 } from 'lucide-react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' +import { useResettableState } from '@/hooks/use-resettable-state' import { Button } from '@/components/ui/button' import { Dialog, @@ -66,11 +67,14 @@ export function EditTagDialog({ open, onOpenChange }: EditTagDialogProps) { const queryClient = useQueryClient() // Form state - const [newTag, setNewTag] = useState('') - const [selectedModels, setSelectedModels] = useState([]) - const [customModel, setCustomModel] = useState('') - const [modelMapping, setModelMapping] = useState('') - const [selectedGroups, setSelectedGroups] = useState([]) + const draftKey = JSON.stringify([open, currentTag]) + const [newTag, setNewTag] = useResettableState(currentTag || '', draftKey) + const [customModel, setCustomModel] = useResettableState('', draftKey) + const [modelMapping, setModelMapping] = useResettableState('', draftKey) + const [selectedGroups, setSelectedGroups] = useResettableState( + [], + draftKey + ) const [isSubmitting, setIsSubmitting] = useState(false) // Fetch tag models @@ -98,23 +102,24 @@ export function EditTagDialog({ open, onOpenChange }: EditTagDialogProps) { allModelsData?.data?.map((m) => m.id).filter(Boolean) || [] const availableGroups = groupsData?.data || [] - // Initialize form when tag changes - useEffect(() => { - if (open && currentTag) { - setNewTag(currentTag) - setModelMapping('') - setSelectedGroups([]) - setCustomModel('') - - // Load tag models - if (tagModelsData?.data) { - const models = tagModelsData.data.split(',').filter(Boolean) - setSelectedModels(models) - } else { - setSelectedModels([]) - } - } - }, [open, currentTag, tagModelsData]) + const [modelDraft, setModelDraft] = useResettableState( + () => ({ + source: tagModelsData?.data, + edited: false, + models: tagModelsData?.data?.split(',').filter(Boolean) || [], + }), + draftKey + ) + if (!modelDraft.edited && modelDraft.source !== tagModelsData?.data) { + setModelDraft({ + source: tagModelsData?.data, + edited: false, + models: tagModelsData?.data?.split(',').filter(Boolean) || [], + }) + } + const selectedModels = modelDraft.models + const setSelectedModels = (models: string[]) => + setModelDraft((previous) => ({ ...previous, edited: true, models })) const handleAddCustomModel = () => { if (!customModel.trim()) return diff --git a/web/default/src/features/channels/components/dialogs/fetch-models-dialog.tsx b/web/default/src/features/channels/components/dialogs/fetch-models-dialog.tsx index cc6e883a..6d24ae6b 100644 --- a/web/default/src/features/channels/components/dialogs/fetch-models-dialog.tsx +++ b/web/default/src/features/channels/components/dialogs/fetch-models-dialog.tsx @@ -145,7 +145,7 @@ export function FetchModelsDialog({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [open, activeChannel?.id, customFetcher]) - const handleFetchModels = async () => { + async function handleFetchModels() { if (!activeChannel && !customFetcher) return setIsFetching(true) diff --git a/web/default/src/features/channels/components/dialogs/multi-key-manage-dialog.tsx b/web/default/src/features/channels/components/dialogs/multi-key-manage-dialog.tsx index 5e34f458..2d7e4e59 100644 --- a/web/default/src/features/channels/components/dialogs/multi-key-manage-dialog.tsx +++ b/web/default/src/features/channels/components/dialogs/multi-key-manage-dialog.tsx @@ -16,11 +16,13 @@ along with this program. If not, see . For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues */ -import { useState, useEffect } from 'react' +import { useEffect, useEffectEvent, useState } from 'react' import { useQueryClient } from '@tanstack/react-query' import { Loader2, RefreshCw, Trash2, Power, PowerOff } from 'lucide-react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' +import { useReadScope } from '@/hooks/use-read-scope' +import { useResettableState } from '@/hooks/use-resettable-state' import { Button } from '@/components/ui/button' import { Dialog, @@ -83,68 +85,85 @@ export function MultiKeyManageDialog({ const { currentRow } = useChannels() const queryClient = useQueryClient() + const channelId = currentRow?.id + const beginRead = useReadScope(JSON.stringify([open, channelId])) + // Data state - const [isLoading, setIsLoading] = useState(false) - const [keys, setKeys] = useState([]) - const [currentPage, setCurrentPage] = useState(1) + const [isLoading, setIsLoading] = useResettableState( + Boolean(open && channelId), + beginRead + ) + const [keys, setKeys] = useResettableState([], beginRead) + const [currentPage, setCurrentPage] = useResettableState(1, beginRead) const [pageSize, setPageSize] = useState(10) - const [total, setTotal] = useState(0) - const [totalPages, setTotalPages] = useState(0) - const [enabledCount, setEnabledCount] = useState(0) - const [manualDisabledCount, setManualDisabledCount] = useState(0) - const [autoDisabledCount, setAutoDisabledCount] = useState(0) + const [total, setTotal] = useResettableState(0, beginRead) + const [totalPages, setTotalPages] = useResettableState(0, beginRead) + const [enabledCount, setEnabledCount] = useResettableState(0, beginRead) + const [manualDisabledCount, setManualDisabledCount] = useResettableState( + 0, + beginRead + ) + const [autoDisabledCount, setAutoDisabledCount] = useResettableState( + 0, + beginRead + ) // UI state - const [statusFilter, setStatusFilter] = useState(null) + const [statusFilter, setStatusFilter] = useResettableState( + null, + beginRead + ) const [confirmAction, setConfirmAction] = - useState(null) + useResettableState(null, beginRead) const [isPerformingAction, setIsPerformingAction] = useState(false) - // Reset and load data when dialog opens + const readInitialKeys = useEffectEvent(() => readKeyStatus(1, pageSize, null)) useEffect(() => { - if (open && currentRow) { - setCurrentPage(1) - setStatusFilter(null) - loadKeyStatus(1, pageSize, null) - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [open, currentRow?.id]) - - const loadKeyStatus = async ( - page: number = currentPage, - size: number = pageSize, - status: number | null = statusFilter - ) => { - if (!currentRow) return - + if (open && channelId) void readInitialKeys() + }, [open, channelId, beginRead]) + + function readKeyStatus(page: number, size: number, status: number | null) { + if (!channelId) return Promise.resolve() + const current = beginRead() + if (!current()) return Promise.resolve() + return getMultiKeyStatus( + channelId, + page, + size, + status === null ? undefined : status + ) + .then((response) => { + if (!current()) return + if (response.success && response.data) { + setKeys(response.data.keys || []) + setTotal(response.data.total || 0) + setCurrentPage(response.data.page || 1) + setPageSize(response.data.page_size || 10) + setTotalPages(response.data.total_pages || 0) + setEnabledCount(response.data.enabled_count || 0) + setManualDisabledCount(response.data.manual_disabled_count || 0) + setAutoDisabledCount(response.data.auto_disabled_count || 0) + } else toast.error(response.message || t('Failed to load key status')) + }) + .catch((error: unknown) => { + if (current()) + toast.error( + error instanceof Error + ? error.message + : t('Failed to load key status') + ) + }) + .finally(() => { + if (current()) setIsLoading(false) + }) + } + function loadKeyStatus( + page = currentPage, + size = pageSize, + status = statusFilter + ) { setIsLoading(true) - try { - const response = await getMultiKeyStatus( - currentRow.id, - page, - size, - status === null ? undefined : status - ) - - if (response.success && response.data) { - setKeys(response.data.keys || []) - setTotal(response.data.total || 0) - setCurrentPage(response.data.page || 1) - setPageSize(response.data.page_size || 10) - setTotalPages(response.data.total_pages || 0) - setEnabledCount(response.data.enabled_count || 0) - setManualDisabledCount(response.data.manual_disabled_count || 0) - setAutoDisabledCount(response.data.auto_disabled_count || 0) - } else { - toast.error(response.message || t('Failed to load key status')) - } - } catch (error: unknown) { - toast.error( - error instanceof Error ? error.message : t('Failed to load key status') - ) - } finally { - setIsLoading(false) - } + return readKeyStatus(page, size, status) } const handleStatusFilterChange = (value: string) => { diff --git a/web/default/src/features/channels/components/dialogs/read-dialog-lifecycle.runtime.test.tsx b/web/default/src/features/channels/components/dialogs/read-dialog-lifecycle.runtime.test.tsx new file mode 100644 index 00000000..41ab9f10 --- /dev/null +++ b/web/default/src/features/channels/components/dialogs/read-dialog-lifecycle.runtime.test.tsx @@ -0,0 +1,372 @@ +/* +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, useState } from 'react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { createReactTestEnvironment } from '@/test/react' +import assert from 'node:assert/strict' +import { after, test } from 'node:test' +import { api } from '@/lib/api' +import { channelSchema } from '../../types' + +const env = createReactTestEnvironment() +await env.setup() +Object.defineProperty(env.dom.window.Element.prototype, 'getAnimations', { + configurable: true, + value: () => [], +}) +const storage = Object.getOwnPropertyDescriptor(globalThis, 'localStorage') +Object.defineProperty(globalThis, 'localStorage', { + configurable: true, + value: env.dom.window.localStorage, +}) +const { fireEvent, within, waitFor } = + await import('@testing-library/react/pure') +const { ChannelsProvider, useChannels } = await import('../channels-provider') +const { MultiKeyManageDialog } = await import('./multi-key-manage-dialog') +const { BalanceQueryDialog } = await import('./balance-query-dialog') +const { EditTagDialog } = await import('./edit-tag-dialog') +const { UserBindingDialog } = + await import('@/features/users/components/dialogs/user-binding-dialog') +after(() => { + env.teardown() + if (storage) Object.defineProperty(globalThis, 'localStorage', storage) + else Reflect.deleteProperty(globalThis, 'localStorage') +}) + +function ChannelHarness({ mode }: { mode: 'keys' | 'usage' }) { + const { setCurrentRow } = useChannels() + const select = (id: number) => + setCurrentRow( + channelSchema.parse({ + id, + type: mode === 'keys' ? 1 : 57, + key: '', + status: 1, + name: `Synthetic channel ${id}`, + created_time: 0, + test_time: 0, + response_time: 0, + balance_updated_time: 0, + }) + ) + return ( + <> + + + {mode === 'keys' ? ( + {}} /> + ) : ( + {}} /> + )} + + ) +} + +test('tag model drafts accept fresh pristine data and protect edits until scope changes', async () => { + const adapter = api.defaults.adapter + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }) + const pending: Array<(models: string) => void> = [] + let tagReads = 0, + writes = 0 + api.defaults.adapter = async (config) => { + if (config.method !== 'get') writes += 1 + let data: unknown = [] + if (config.url === '/api/channel/tag/models') { + if (config.params.tag === 'first') { + tagReads += 1 + data = + tagReads <= 3 + ? await new Promise((resolve) => pending.push(resolve)) + : 'server-refetched' + } else data = 'second-model' + } + return { + config, + status: 200, + statusText: 'OK', + headers: {}, + data: { success: true, data }, + } + } + function Harness() { + const { setCurrentTag } = useChannels() + const [open, setOpen] = useState(true) + const [, setRevision] = useState(0) + return ( + <> + + + + + + + ) + } + const view = await env.render( + + + + + + ) + const body = within(env.dom.window.document.body), + host = within(view.container) + try { + await view.click(host.getByText('Edit first tag')) + await waitFor(() => assert.equal(pending.length, 1)) + await act(async () => { + fireEvent.change( + body.getByPlaceholderText('Enter new tag name or leave empty'), + { target: { value: 'renamed-first' } } + ) + pending[0]('server-initial') + }) + await waitFor(() => assert.ok(body.queryByText(/server-initial/))) + let pristineRefetch: Promise | undefined + await act(async () => { + pristineRefetch = client.refetchQueries({ + queryKey: ['tag-models', 'first'], + exact: true, + }) + }) + await waitFor(() => assert.equal(pending.length, 2)) + await act(async () => { + pending[1]('server-pristine') + await pristineRefetch + }) + assert.equal( + client.getQueryData<{ data: string }>(['tag-models', 'first'])?.data, + 'server-pristine' + ) + await view.click(host.getByText('Render tag host again')) + assert.ok(body.queryByText(/server-pristine/)) + await act(async () => { + fireEvent.change( + body.getByPlaceholderText('Custom model (comma-separated)'), + { target: { value: 'local-model' } } + ) + }) + await view.click(body.getByRole('button', { name: 'Add' })) + assert.ok(body.queryByText(/local-model/)) + let refetch: Promise | undefined + await act(async () => { + refetch = client.refetchQueries({ + queryKey: ['tag-models', 'first'], + exact: true, + }) + }) + await waitFor(() => assert.equal(pending.length, 3)) + await act(async () => { + pending[2]('server-refetched') + await refetch + }) + assert.equal( + client.getQueryData<{ data: string }>(['tag-models', 'first'])?.data, + 'server-refetched' + ) + await view.click(host.getByText('Render tag host again')) + assert.ok(body.queryByText(/local-model/)) + assert.ok(body.queryByText(/server-pristine/)) + assert.equal( + ( + body.getByPlaceholderText( + 'Enter new tag name or leave empty' + ) as HTMLInputElement + ).value, + 'renamed-first' + ) + await view.click(host.getByText('Toggle tag editor')) + await view.click(host.getByText('Toggle tag editor')) + await waitFor(() => assert.ok(body.queryByText(/server-refetched/))) + assert.equal(body.queryByText(/local-model/), null) + await view.click(host.getByText('Edit second tag')) + await waitFor(() => assert.ok(body.queryByText(/second-model/))) + assert.equal(body.queryByText(/server-refetched/), null) + assert.equal(writes, 0) + assert.ok(tagReads >= 3) + } finally { + pending.forEach((release) => release('server-refetched')) + await view.unmount() + client.clear() + api.defaults.adapter = adapter + } +}) + +test('late multi-key results cannot replace the current channel keys', async () => { + const adapter = api.defaults.adapter + const pending = new Map void>() + const requests: number[] = [] + api.defaults.adapter = async (config) => { + assert.equal(config.url, '/api/channel/multi_key/manage') + const params = JSON.parse(config.data) + assert.equal(params.action, 'get_key_status') + const id = Number(params.channel_id) + requests.push(id) + await new Promise((resolve) => pending.set(id, resolve)) + return { + config, + status: 200, + statusText: 'OK', + headers: {}, + data: { + success: true, + data: { + keys: [{ index: 0, status: 2, reason: `synthetic-reason-${id}` }], + total: 1, + page: 1, + page_size: 10, + total_pages: 1, + enabled_count: 0, + manual_disabled_count: 1, + auto_disabled_count: 0, + }, + }, + } + } + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }) + const view = await env.render( + + + + + + ) + const body = within(env.dom.window.document.body) + try { + await view.click(within(view.container).getByText('First channel')) + await view.click(within(view.container).getByText('Second channel')) + assert.ok(pending.has(2)) + await act(async () => pending.get(2)!()) + assert.ok(body.queryByText('synthetic-reason-2')) + await act(async () => pending.get(1)!()) + assert.ok(body.queryByText('synthetic-reason-2')) + assert.equal(body.queryByText('synthetic-reason-1'), null) + assert.deepEqual(requests, [1, 2]) + } finally { + pending.forEach((resolve) => resolve()) + await view.unmount() + client.clear() + api.defaults.adapter = adapter + } +}) + +test('Codex usage loads the new channel even when its type stays the same', async () => { + const adapter = api.defaults.adapter + const pending = new Map void>() + api.defaults.adapter = async (config) => { + const id = Number(config.url?.match(/channel\/(\d+)\/codex\/usage/)?.[1]) + assert.ok(id === 1 || id === 2) + await new Promise((resolve) => pending.set(id, resolve)) + return { + config, + status: 200, + statusText: 'OK', + headers: {}, + data: { + success: true, + data: { email: `codex-${id}@example.invalid`, plan_type: 'pro' }, + }, + } + } + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }) + const view = await env.render( + + + + + + ) + const body = within(env.dom.window.document.body) + try { + await view.click(within(view.container).getByText('First channel')) + await view.click(within(view.container).getByText('Second channel')) + assert.ok(pending.has(2), 'changing channel ID must load its usage') + await act(async () => pending.get(2)!()) + assert.ok(body.queryByText('codex-2@example.invalid')) + await act(async () => pending.get(1)!()) + assert.ok(body.queryByText('codex-2@example.invalid')) + assert.equal(body.queryByText('codex-1@example.invalid'), null) + } finally { + pending.forEach((resolve) => resolve()) + await view.unmount() + client.clear() + api.defaults.adapter = adapter + } +}) + +test('late binding details cannot replace another selected user', async () => { + const adapter = api.defaults.adapter + const pending = new Map void>() + api.defaults.adapter = async (config) => { + assert.equal(config.method, 'get') + let data: unknown = [] + const match = config.url?.match(/^\/api\/user\/(\d+)$/) + if (match) { + const id = Number(match[1]) + await new Promise((resolve) => pending.set(id, resolve)) + data = { + id, + username: `synthetic-user-${id}`, + email: `binding-${id}@example.invalid`, + } + } else if (config.url === '/api/status') data = { email_verification: true } + return { + config, + status: 200, + statusText: 'OK', + headers: {}, + data: { success: true, data }, + } + } + function Harness() { + const [id, setId] = useState(1) + return ( + <> + + {}} /> + + ) + } + const view = await env.render() + const body = within(env.dom.window.document.body) + try { + await waitFor(() => assert.ok(pending.has(1))) + await view.click(within(view.container).getByText('Another binding user')) + assert.ok(pending.has(2)) + await act(async () => pending.get(2)!()) + assert.ok(body.queryByText('binding-2@example.invalid')) + await act(async () => pending.get(1)!()) + assert.ok(body.queryByText('binding-2@example.invalid')) + assert.equal(body.queryByText('binding-1@example.invalid'), null) + } finally { + pending.forEach((resolve) => resolve()) + await view.unmount() + api.defaults.adapter = adapter + } +}) diff --git a/web/default/src/features/channels/components/dialogs/tag-batch-edit-dialog.runtime.test.tsx b/web/default/src/features/channels/components/dialogs/tag-batch-edit-dialog.runtime.test.tsx new file mode 100644 index 00000000..6de11cdf --- /dev/null +++ b/web/default/src/features/channels/components/dialogs/tag-batch-edit-dialog.runtime.test.tsx @@ -0,0 +1,193 @@ +/* +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, useState } from 'react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { createReactTestEnvironment } from '@/test/react' +import { waitFor, within } from '@testing-library/react' +import assert from 'node:assert/strict' +import { after, test } from 'node:test' +import { api } from '@/lib/api' + +const testEnv = createReactTestEnvironment() +await testEnv.setup() +const previousStorage = Object.getOwnPropertyDescriptor( + globalThis, + 'localStorage' +) +Object.defineProperty(globalThis, 'localStorage', { + configurable: true, + value: testEnv.dom.window.localStorage, +}) + +const { ChannelsProvider, useChannels } = await import('../channels-provider') +const { TagBatchEditDialog } = await import('./tag-batch-edit-dialog') +after(() => { + testEnv.teardown() + if (previousStorage) + Object.defineProperty(globalThis, 'localStorage', previousStorage) + else Reflect.deleteProperty(globalThis, 'localStorage') +}) + +test('a late response for a previous tag cannot overwrite the current tag', async () => { + const originalAdapter = api.defaults.adapter + const pending = new Map void>() + const requests: string[] = [] + let groupRequests = 0 + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }) + api.defaults.adapter = async (config) => { + assert.equal(config.method, 'get') + if (config.url === '/api/group/') groupRequests += 1 + let data: unknown = { success: true, data: [] } + if (config.url === '/api/channel/tag/models') { + const tag = String(config.params.tag) + requests.push(tag) + const models = await new Promise((resolve) => { + pending.set(tag, resolve) + }) + data = { success: true, data: models } + } + return { data, config, status: 200, statusText: 'OK', headers: {} } + } + function Harness() { + const { setCurrentTag } = useChannels() + const [, setRevision] = useState(0) + return ( + <> + + + + {}} /> + + ) + } + const view = await testEnv.render( + + + + + + ) + const first = within(view.container).getByRole('button', { + name: 'Select first tag', + }) + const second = within(view.container).getByRole('button', { + name: 'Select second tag', + }) + const rerender = within(view.container).getByRole('button', { + name: 'Rerender', + }) + const fields = within(testEnv.dom.window.document.body) + try { + await view.click(first) + await waitFor(() => assert.ok(pending.has('first'))) + await view.click(second) + await waitFor(() => assert.ok(pending.has('second'))) + await act(async () => pending.get('second')!('model-second')) + await waitFor(() => { + assert.equal( + (fields.getByLabelText('Models') as HTMLTextAreaElement).value, + 'model-second' + ) + }) + await act(async () => pending.get('first')!('model-first')) + await waitFor(() => { + assert.equal( + (fields.getByLabelText('Models') as HTMLTextAreaElement).value, + 'model-second' + ) + assert.equal( + (fields.getByLabelText('Tag Name') as HTMLInputElement).value, + 'second' + ) + }) + await view.click(rerender) + await act(async () => { + await testEnv.i18n.changeLanguage('fr') + }) + assert.deepEqual(requests, ['first', 'second']) + assert.equal(groupRequests, 1) + } finally { + await view.unmount() + client.clear() + api.defaults.adapter = originalAdapter + } +}) + +test('tag models become editable without waiting for an unused all-models request', async () => { + const originalAdapter = api.defaults.adapter + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }) + let auxiliaryRequests = 0 + let releaseAuxiliary: () => void = () => {} + api.defaults.adapter = async (config) => { + assert.equal(config.method, 'get') + let data: unknown = { success: true, data: [] } + if (config.url === '/api/channel/tag/models') { + data = { success: true, data: 'tag-only-model' } + } else if (config.url === '/api/channel/models') { + auxiliaryRequests += 1 + data = await new Promise((resolve) => { + releaseAuxiliary = () => resolve({ success: true, data: [] }) + }) + } + return { data, config, status: 200, statusText: 'OK', headers: {} } + } + function Harness() { + const { setCurrentTag } = useChannels() + return ( + <> + + {}} /> + + ) + } + const view = await testEnv.render( + + + + + + ) + try { + await view.click( + within(view.container).getByRole('button', { name: 'Select ready tag' }) + ) + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)) + }) + const models = within(testEnv.dom.window.document.body).queryByLabelText( + 'Models' + ) as HTMLTextAreaElement | null + assert.equal(models !== null, true) + assert.equal(models?.value, 'tag-only-model') + assert.equal(auxiliaryRequests, 0) + } finally { + await act(async () => releaseAuxiliary()) + await view.unmount() + client.clear() + api.defaults.adapter = originalAdapter + } +}) diff --git a/web/default/src/features/channels/components/dialogs/tag-batch-edit-dialog.tsx b/web/default/src/features/channels/components/dialogs/tag-batch-edit-dialog.tsx index a8dc78cc..50499588 100644 --- a/web/default/src/features/channels/components/dialogs/tag-batch-edit-dialog.tsx +++ b/web/default/src/features/channels/components/dialogs/tag-batch-edit-dialog.tsx @@ -16,7 +16,7 @@ along with this program. If not, see . For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues */ -import { useState, useEffect, useMemo } from 'react' +import { useState, useEffect, useEffectEvent, useMemo } from 'react' import { useQuery, useQueryClient } from '@tanstack/react-query' import { Loader2, AlertCircle } from 'lucide-react' import { useTranslation } from 'react-i18next' @@ -36,12 +36,7 @@ import { Label } from '@/components/ui/label' import { Skeleton } from '@/components/ui/skeleton' import { Textarea } from '@/components/ui/textarea' import { MultiSelect } from '@/components/multi-select' -import { - getTagModels, - editTagChannels, - getAllModels, - getGroups, -} from '../../api' +import { getTagModels, editTagChannels, getGroups } from '../../api' import { channelsQueryKeys } from '../../lib' import type { TagOperationParams } from '../../types' import { useChannels } from '../channels-provider' @@ -52,28 +47,45 @@ type TagBatchEditDialogProps = { onOpenChange: (open: boolean) => void } -export function TagBatchEditDialog({ +export function TagBatchEditDialog(props: TagBatchEditDialogProps) { + const { currentTag } = useChannels() + const { data: groupsData, isLoading: isLoadingGroups } = useQuery({ + queryKey: ['groups'], + queryFn: getGroups, + }) + return ( + + ) +} + +function TagBatchEditDialogContent({ open, onOpenChange, -}: TagBatchEditDialogProps) { + currentTag, + groupsData, + isLoadingGroups, +}: TagBatchEditDialogProps & { + currentTag: string | null + groupsData: Awaited> | undefined + isLoadingGroups: boolean +}) { const { t } = useTranslation() - const { currentTag } = useChannels() const queryClient = useQueryClient() - const [isLoading, setIsLoading] = useState(false) + const [isLoading, setIsLoading] = useState(Boolean(open && currentTag)) const [isSaving, setIsSaving] = useState(false) // Form fields - const [newTag, setNewTag] = useState('') + const [newTag, setNewTag] = useState(currentTag ?? '') const [models, setModels] = useState('') const [modelMapping, setModelMapping] = useState('') const [groups, setGroups] = useState([]) - // Fetch available groups - const { data: groupsData, isLoading: isLoadingGroups } = useQuery({ - queryKey: ['groups'], - queryFn: getGroups, - }) - // Transform groups to multi-select options const groupOptions = useMemo(() => { if (!groupsData?.data) return [] @@ -84,40 +96,36 @@ export function TagBatchEditDialog({ })) }, [groupsData, groups]) - useEffect(() => { - if (open && currentTag) { - loadTagData() - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [open, currentTag]) - - const loadTagData = async () => { - if (!currentTag) return - - setIsLoading(true) - try { - // Fetch current tag models - const tagModelsResponse = await getTagModels(currentTag) - if (tagModelsResponse.success && tagModelsResponse.data) { - setModels(tagModelsResponse.data) - } + const reportLoadError = useEffectEvent((error: unknown) => { + toast.error( + error instanceof Error ? error.message : t('Failed to load tag data') + ) + }) - // Fetch all available models (for future use if needed) - const allModelsResponse = await getAllModels() - if (allModelsResponse.success && allModelsResponse.data) { - // Available models could be used for autocomplete in the future + useEffect(() => { + if (!open || !currentTag) return + const tag = currentTag + let cancelled = false + async function loadTagData() { + try { + const tagModelsResponse = await getTagModels(tag) + if (cancelled) return + if (tagModelsResponse.success && tagModelsResponse.data) { + setModels(tagModelsResponse.data) + } + } catch (error: unknown) { + if (!cancelled) { + reportLoadError(error) + } + } finally { + if (!cancelled) setIsLoading(false) } - - // Initialize new tag with current tag name - setNewTag(currentTag) - } catch (_error: unknown) { - toast.error( - _error instanceof Error ? _error.message : t('Failed to load tag data') - ) - } finally { - setIsLoading(false) } - } + void loadTagData() + return () => { + cancelled = true + } + }, [open, currentTag]) const handleSave = async () => { if (!currentTag) return diff --git a/web/default/src/features/channels/components/drawers/channel-mutate-drawer.tsx b/web/default/src/features/channels/components/drawers/channel-mutate-drawer.tsx index 7e3ca5a5..47413587 100644 --- a/web/default/src/features/channels/components/drawers/channel-mutate-drawer.tsx +++ b/web/default/src/features/channels/components/drawers/channel-mutate-drawer.tsx @@ -2332,6 +2332,7 @@ export function ChannelMutateDrawer({ )} { diff --git a/web/default/src/features/dashboard/components/models/consumption-distribution-chart.tsx b/web/default/src/features/dashboard/components/models/consumption-distribution-chart.tsx index cc509187..de0cb4bb 100644 --- a/web/default/src/features/dashboard/components/models/consumption-distribution-chart.tsx +++ b/web/default/src/features/dashboard/components/models/consumption-distribution-chart.tsx @@ -73,9 +73,11 @@ export function ConsumptionDistributionChart( >(null) const timeGranularity = props.timeGranularity ?? DEFAULT_TIME_GRANULARITY - useEffect(() => { + const [previousDefault, setPreviousDefault] = useState(props.defaultChartType) + if (previousDefault !== props.defaultChartType) { + setPreviousDefault(props.defaultChartType) if (props.defaultChartType) setChartType(props.defaultChartType) - }, [props.defaultChartType]) + } useEffect(() => { const updateTheme = async () => { diff --git a/web/default/src/features/dashboard/components/models/model-charts.tsx b/web/default/src/features/dashboard/components/models/model-charts.tsx index f363eca6..a778ae31 100644 --- a/web/default/src/features/dashboard/components/models/model-charts.tsx +++ b/web/default/src/features/dashboard/components/models/model-charts.tsx @@ -71,9 +71,11 @@ export function ModelCharts(props: ModelChartsProps) { >(null) const timeGranularity = props.timeGranularity ?? DEFAULT_TIME_GRANULARITY - useEffect(() => { + const [previousDefault, setPreviousDefault] = useState(props.defaultChartTab) + if (previousDefault !== props.defaultChartTab) { + setPreviousDefault(props.defaultChartTab) if (props.defaultChartTab) setActiveTab(props.defaultChartTab) - }, [props.defaultChartTab]) + } useEffect(() => { const updateTheme = async () => { diff --git a/web/default/src/features/dashboard/components/users/user-charts.tsx b/web/default/src/features/dashboard/components/users/user-charts.tsx index 137a58e6..692c853a 100644 --- a/web/default/src/features/dashboard/components/users/user-charts.tsx +++ b/web/default/src/features/dashboard/components/users/user-charts.tsx @@ -148,7 +148,6 @@ export function UserCharts() { t, topUserLimit, customization.preset, - customization.radius, ] ) diff --git a/web/default/src/features/keys/components/api-key-routing-editor.test.tsx b/web/default/src/features/keys/components/api-key-routing-editor.test.tsx index 778d35d0..cae1abad 100644 --- a/web/default/src/features/keys/components/api-key-routing-editor.test.tsx +++ b/web/default/src/features/keys/components/api-key-routing-editor.test.tsx @@ -29,6 +29,7 @@ import type { ApiKeyGroupOption } from './api-key-group-combobox' let ApiKeyRoutingEditor: typeof import('./api-key-routing-editor').ApiKeyRoutingEditor let cleanup: typeof import('@testing-library/react/pure').cleanup let fireEvent: typeof import('@testing-library/react/pure').fireEvent +let isInaccessible: typeof import('@testing-library/react/pure').isInaccessible let render: typeof import('@testing-library/react/pure').render let within: typeof import('@testing-library/react/pure').within @@ -87,7 +88,7 @@ before(async (): Promise => { }), }) // React and Base UI must detect the browser before mounting interactive portals. - ;({ cleanup, fireEvent, render, within } = + ;({ cleanup, fireEvent, isInaccessible, render, within } = await import('@testing-library/react/pure')) ;({ ApiKeyRoutingEditor } = await import('./api-key-routing-editor')) }) @@ -147,6 +148,37 @@ function RoutingHarness(props: RoutingHarnessProps): ReactElement { ) } +async function findRoutingOptions( + view: ReturnType +): Promise> { + // Compute each visible option's accessible name once instead of rescanning + // every option for each group. Keep the same role, visibility and name checks. + const names = new Map() + const options = await view.findAllByRole('option', { + name: (name: string, element: Element): boolean => { + names.set(element, name) + return true + }, + }) + const result = new Map() + for (const option of options) { + const name = names.get(option) + assert.ok(name !== undefined) + assert.equal(result.has(name), false, `duplicate option name: ${name}`) + result.set(name, option) + } + return result +} + +function routingOption( + options: Map, + name: string +): HTMLElement { + const option = options.get(name) + assert.ok(option, `missing accessible routing option: ${name}`) + return option +} + describe('ApiKeyRoutingEditor', (): void => { for (const initialGroups of [[], groups]) { test(`clears ${initialGroups.length} stored manual selections when automatic routing is disabled`, async (): Promise => { @@ -164,15 +196,13 @@ describe('ApiKeyRoutingEditor', (): void => { ) fireEvent.click(view.getByRole('combobox', { name: 'Select groups' })) - const options = await view.findAllByRole('option') - assert.equal(options.length, availableGroups.length) + const options = await findRoutingOptions(view) + assert.equal(options.size, availableGroups.length) for (const group of availableGroups) { - const option = view.getByRole('option', { - name: `${group}1x Ratio`, - }) + const option = routingOption(options, `${group}1x Ratio`) assert.notEqual(option.getAttribute('aria-disabled'), 'true') } - fireEvent.click(view.getByRole('option', { name: 'group-141x Ratio' })) + fireEvent.click(routingOption(options, 'group-141x Ratio')) within(view.getByRole('combobox', { name: 'Select groups' })).getByText( 'group-14' ) @@ -225,27 +255,28 @@ describe('ApiKeyRoutingEditor', (): void => { fireEvent.click(trigger) assert.equal(trigger.getAttribute('aria-expanded'), 'true') - const options = await view.findAllByRole('option') - assert.equal(options.length, availableGroups.length) + const options = await findRoutingOptions(view) + assert.equal(options.size, availableGroups.length) for (const group of availableGroups) { - const option = view.getByRole('option', { - name: `${group}${group}1x Ratio`, - }) + const option = routingOption(options, `${group}${group}1x Ratio`) assert.equal( option.getAttribute('aria-disabled'), String(!groups.includes(group)) ) } - fireEvent.click( - view.getByRole('option', { name: 'group-14group-141x Ratio' }) - ) + fireEvent.click(routingOption(options, 'group-14group-141x Ratio')) assert.equal(changedGroups, undefined) - const removeButtons = view.getAllByRole('button', { - name: /^Remove group-/, - }) + const removeButtons = view.getAllByLabelText(/^Remove group-/) assert.equal(removeButtons.length, MAX_MANUAL_ROUTING_GROUPS) for (const removeButton of removeButtons) { + assert.equal(removeButton.tagName, 'BUTTON') + assert.ok( + removeButton.getAttribute('role') === null || + removeButton.getAttribute('role') === 'button' + ) + assert.equal(removeButton.getAttribute('aria-labelledby'), null) + assert.equal(isInaccessible(removeButton), false) assert.equal(trigger.contains(removeButton), false) } diff --git a/web/default/src/features/models/components/dialogs/extend-deployment-dialog.tsx b/web/default/src/features/models/components/dialogs/extend-deployment-dialog.tsx index dfac6469..b73e3c79 100644 --- a/web/default/src/features/models/components/dialogs/extend-deployment-dialog.tsx +++ b/web/default/src/features/models/components/dialogs/extend-deployment-dialog.tsx @@ -16,11 +16,12 @@ along with this program. If not, see . For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues */ -import { useEffect, useMemo, useState } from 'react' +import { useMemo, useState } from 'react' import { useQuery, useQueryClient } from '@tanstack/react-query' import { Loader2 } from 'lucide-react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' +import { useResettableState } from '@/hooks/use-resettable-state' import { Button } from '@/components/ui/button' import { Dialog, @@ -50,13 +51,9 @@ export function ExtendDeploymentDialog({ }) { const { t } = useTranslation() const queryClient = useQueryClient() - const [hours, setHours] = useState(1) + const [hours, setHours] = useResettableState(1, `${open}:${deploymentId}`) const [isSubmitting, setIsSubmitting] = useState(false) - useEffect(() => { - if (open) setHours(1) - }, [open]) - const { data: detailsRes, isLoading: isLoadingDetails } = useQuery({ queryKey: ['deployment-details-for-extend', deploymentId], queryFn: () => (deploymentId ? getDeployment(deploymentId) : null), diff --git a/web/default/src/features/models/components/dialogs/rename-deployment-dialog.tsx b/web/default/src/features/models/components/dialogs/rename-deployment-dialog.tsx index 99ae43d4..9f26c495 100644 --- a/web/default/src/features/models/components/dialogs/rename-deployment-dialog.tsx +++ b/web/default/src/features/models/components/dialogs/rename-deployment-dialog.tsx @@ -16,11 +16,12 @@ along with this program. If not, see . For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues */ -import { useEffect, useMemo, useState } from 'react' +import { useMemo, useState } from 'react' import { useQuery, useQueryClient } from '@tanstack/react-query' import { Loader2 } from 'lucide-react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' +import { useResettableState } from '@/hooks/use-resettable-state' import { Button } from '@/components/ui/button' import { Dialog, @@ -46,13 +47,12 @@ export function RenameDeploymentDialog({ }) { const { t } = useTranslation() const queryClient = useQueryClient() - const [name, setName] = useState(currentName || '') + const [name, setName] = useResettableState( + currentName || '', + JSON.stringify([open, deploymentId, currentName]) + ) const [isSubmitting, setIsSubmitting] = useState(false) - useEffect(() => { - if (open) setName(currentName || '') - }, [open, currentName]) - const trimmed = name.trim() const { data: checkRes, isFetching: isChecking } = useQuery({ diff --git a/web/default/src/features/models/components/dialogs/sync-wizard-dialog.runtime.test.tsx b/web/default/src/features/models/components/dialogs/sync-wizard-dialog.runtime.test.tsx new file mode 100644 index 00000000..1392c81f --- /dev/null +++ b/web/default/src/features/models/components/dialogs/sync-wizard-dialog.runtime.test.tsx @@ -0,0 +1,78 @@ +/* +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, useState } from 'react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { createReactTestEnvironment } from '@/test/react' +import { within } from '@testing-library/react' +import assert from 'node:assert/strict' +import { after, test } from 'node:test' + +const env = createReactTestEnvironment() +await env.setup() +const { ModelsProvider } = await import('../models-provider') +const { SyncWizardDialog } = await import('./sync-wizard-dialog') +after(() => env.teardown()) + +test('sync language draft survives unrelated renders and resets on reopen', async () => { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }) + function Harness() { + const [open, setOpen] = useState(true) + const [, setRevision] = useState(0) + return ( + <> + + + + + ) + } + const view = await env.render( + + + + + + ) + const body = within(env.dom.window.document.body) + const selected = (locale: string) => + body + .getByRole('radio', { name: locale === 'en' ? 'English' : 'Chinese' }) + .getAttribute('aria-checked') + try { + await view.click(body.getByRole('radio', { name: 'English' })) + assert.equal(selected('en'), 'true') + await view.click(within(view.container).getByText('Rerender host')) + await act(async () => { + await env.i18n.changeLanguage('fr') + }) + assert.equal(selected('en'), 'true') + await view.click(within(view.container).getByText('Toggle wizard')) + await view.click(within(view.container).getByText('Toggle wizard')) + assert.equal(selected('zh'), 'true') + } finally { + await view.unmount() + client.clear() + } +}) diff --git a/web/default/src/features/models/components/dialogs/sync-wizard-dialog.tsx b/web/default/src/features/models/components/dialogs/sync-wizard-dialog.tsx index f365f743..800c4d2d 100644 --- a/web/default/src/features/models/components/dialogs/sync-wizard-dialog.tsx +++ b/web/default/src/features/models/components/dialogs/sync-wizard-dialog.tsx @@ -16,13 +16,14 @@ along with this program. If not, see . For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues */ -import { useEffect, useState } from 'react' +import { useState } from 'react' import { useQueryClient } from '@tanstack/react-query' import { Loader2, RefreshCw } from 'lucide-react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import { cn } from '@/lib/utils' import { useIsMobile } from '@/hooks/use-mobile' +import { useResettableState } from '@/hooks/use-resettable-state' import { Button } from '@/components/ui/button' import { Dialog, @@ -59,27 +60,30 @@ export function SyncWizardDialog({ syncWizardOptions, } = useModels() const isMobile = useIsMobile() - const [locale, setLocale] = useState('zh') - const [source, setSource] = useState('official') const [isSyncing, setIsSyncing] = useState(false) // Get translated options const SYNC_SOURCE_OPTIONS = getSyncSourceOptions(t) const SYNC_LOCALE_OPTIONS = getSyncLocaleOptions(t) - useEffect(() => { - if (open) { - setLocale(syncWizardOptions.locale || 'zh') - const preferredSource = SYNC_SOURCE_OPTIONS.find( - (option) => option.value === syncWizardOptions.source - ) - setSource( - preferredSource && !preferredSource.disabled - ? (preferredSource.value as SyncSource) - : 'official' - ) - } - }, [open, syncWizardOptions, SYNC_SOURCE_OPTIONS]) + const draftKey = JSON.stringify([ + open, + syncWizardOptions.locale, + syncWizardOptions.source, + ]) + const [locale, setLocale] = useResettableState( + syncWizardOptions.locale || 'zh', + draftKey + ) + const preferredSource = SYNC_SOURCE_OPTIONS.find( + (option) => option.value === syncWizardOptions.source + ) + const [source, setSource] = useResettableState( + preferredSource && !preferredSource.disabled + ? preferredSource.value + : 'official', + draftKey + ) const handleSync = async () => { setIsSyncing(true) diff --git a/web/default/src/features/pricing/components/model-details.runtime.test.tsx b/web/default/src/features/pricing/components/model-details.runtime.test.tsx index 71ee53d0..760b409f 100644 --- a/web/default/src/features/pricing/components/model-details.runtime.test.tsx +++ b/web/default/src/features/pricing/components/model-details.runtime.test.tsx @@ -21,18 +21,54 @@ import { createReactTestEnvironment } from '@/test/react' import { within } from '@testing-library/react' import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' -import { after, before, describe, test } from 'node:test' +import { after, afterEach, before, beforeEach, describe, test } from 'node:test' import { useSystemConfigStore } from '@/stores/system-config-store' +import { api } from '@/lib/api' +import type { PerformanceMetricsData } from '@/features/performance-metrics/types' import type { PricingModel } from '../types' import { DynamicPricingBreakdown } from './dynamic-pricing-breakdown' import { ModelDetailsContent } from './model-details' import { ModelTierPricing } from './model-tier-pricing' const testEnv = createReactTestEnvironment() +const originalAdapter = api.defaults.adapter +let unexpectedRequests = 0 +let metricsReads: string[] = [] before(() => testEnv.setup()) -after(() => testEnv.teardown()) +beforeEach(() => { + unexpectedRequests = 0 + metricsReads = [] + api.defaults.adapter = async (config) => { + const modelName = config.params?.model + if ( + config.method !== 'get' || + config.url !== '/api/perf-metrics' || + config.params?.hours !== 24 || + (modelName !== 'time-priced-model' && modelName !== 'MiniMax-H3') + ) { + unexpectedRequests += 1 + throw new Error('Unexpected request in model-pricing fixture') + } + metricsReads.push(modelName) + const data: PerformanceMetricsData = { + success: true, + data: { model_name: modelName, groups: [] }, + } + return { config, status: 200, statusText: 'OK', headers: {}, data } + } +}) + +afterEach(() => { + api.defaults.adapter = originalAdapter + assert.equal(unexpectedRequests, 0, 'All model-pricing reads need a fixture') +}) + +after(() => { + api.defaults.adapter = originalAdapter + testEnv.teardown() +}) describe('Model square expression pricing', () => { const displayOptions = { @@ -258,7 +294,9 @@ describe('Model square expression pricing', () => { }) test('separates prices from conditions in the actual model square without changing the log layout', async () => { - const queryClient = new QueryClient() + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }) const view = await testEnv.render( { ) try { + assert.deepEqual(metricsReads, ['time-priced-model']) const square = within(view.container).getByRole('region', { name: 'Tiered price table', }) @@ -490,7 +529,9 @@ describe('ModelDetailsContent structured task pricing', () => { queryClient: QueryClient view: Awaited> }> { - const queryClient = new QueryClient() + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }) const view = await testEnv.render( . For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues */ -import { useEffect, useState, useMemo, useCallback } from 'react' +import { useState, useMemo, useCallback } from 'react' import { useQuery } from '@tanstack/react-query' import { CalendarDays, @@ -122,13 +122,10 @@ export function CheckinCalendarCard({ const checkedToday = checkinData?.stats?.checked_in_today === true const todayAward = checkinRecordsMap[todayString] - useEffect(() => { - if (initialLoaded) return - if (isLoading) return - if (!checkinData) return + if (!initialLoaded && !isLoading && checkinData) { setCollapsed(checkedToday) setInitialLoaded(true) - }, [checkinData, checkedToday, initialLoaded, isLoading]) + } const shouldTriggerTurnstile = useCallback( (message?: string) => { diff --git a/web/default/src/features/profile/components/language-preferences-card.tsx b/web/default/src/features/profile/components/language-preferences-card.tsx index 7807ef47..4bdf1284 100644 --- a/web/default/src/features/profile/components/language-preferences-card.tsx +++ b/web/default/src/features/profile/components/language-preferences-card.tsx @@ -16,7 +16,7 @@ along with this program. If not, see . For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues */ -import { useEffect, useMemo, useState } from 'react' +import { useMemo, useState } from 'react' import { INTERFACE_LANGUAGE_OPTIONS, normalizeInterfaceLanguage, @@ -25,6 +25,7 @@ import { Languages, Loader2 } from 'lucide-react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import { useAuthStore } from '@/stores/auth-store' +import { useResettableState } from '@/hooks/use-resettable-state' import { Select, SelectContent, @@ -53,11 +54,10 @@ export function LanguagePreferencesCard(props: LanguagePreferencesCardProps) { return normalizeInterfaceLanguage(settings.language || i18n.language) }, [props.profile?.setting, i18n.language]) - const [currentLanguage, setCurrentLanguage] = useState(savedLanguage) - - useEffect(() => { - setCurrentLanguage(savedLanguage) - }, [savedLanguage]) + const [currentLanguage, setCurrentLanguage] = useResettableState( + savedLanguage, + savedLanguage + ) const handleLanguageChange = async (language: string | null) => { if (!language) return diff --git a/web/default/src/features/profile/components/sidebar-modules-card.tsx b/web/default/src/features/profile/components/sidebar-modules-card.tsx index 736037e4..9caa1ec9 100644 --- a/web/default/src/features/profile/components/sidebar-modules-card.tsx +++ b/web/default/src/features/profile/components/sidebar-modules-card.tsx @@ -22,6 +22,8 @@ import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import { useAuthStore } from '@/stores/auth-store' import { api } from '@/lib/api' +import { useReadScope } from '@/hooks/use-read-scope' +import { useResettableState } from '@/hooks/use-resettable-state' import { Button } from '@/components/ui/button' import { Card, @@ -46,12 +48,28 @@ type SectionDef = { modules: { key: string; title: string; description: string }[] } +function buildDefaultSidebarConfig( + sections: SectionDef[] +): SidebarModulesConfig { + const defaults: SidebarModulesConfig = {} + for (const section of sections) { + defaults[section.key] = { enabled: true } + for (const module of section.modules) + defaults[section.key][module.key] = true + } + return defaults +} + export function SidebarModulesCard() { const { t } = useTranslation() const [loading, setLoading] = useState(false) - const [config, setConfig] = useState({}) const currentUser = useAuthStore((s) => s.auth.user) const setUser = useAuthStore((s) => s.auth.setUser) + const beginRead = useReadScope(currentUser?.id) + const [config, setConfig] = useResettableState( + {}, + currentUser?.id + ) const sectionDefs: SectionDef[] = [ { @@ -122,26 +140,34 @@ export function SidebarModulesCard() { }, ] - const loadConfig = useCallback(async () => { - try { - const res = await api.get('/api/user/self') - if (res.data.success && res.data.data?.sidebar_modules) { - const raw = res.data.data.sidebar_modules - const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw - setConfig(parsed) - } else { - const defaults: SidebarModulesConfig = {} - for (const sec of sectionDefs) { - defaults[sec.key] = { enabled: true } - for (const mod of sec.modules) defaults[sec.key][mod.key] = true + const loadConfig = useCallback(() => { + const current = beginRead() + if (!current()) return Promise.resolve() + return api + .get('/api/user/self') + .then((res) => { + if (!current()) return + let parsed: unknown = res.data.success + ? res.data.data?.sidebar_modules + : undefined + if (typeof parsed === 'string') { + try { + parsed = JSON.parse(parsed) + } catch { + parsed = null + } } - setConfig(defaults) - } - } catch { - /* ignore */ - } + setConfig( + parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? (parsed as SidebarModulesConfig) + : buildDefaultSidebarConfig(sectionDefs) + ) + }) + .catch(() => { + /* retain the last valid configuration */ + }) // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) + }, [beginRead, setConfig]) useEffect(() => { loadConfig() @@ -190,12 +216,7 @@ export function SidebarModulesCard() { } const handleReset = () => { - const defaults: SidebarModulesConfig = {} - for (const sec of sectionDefs) { - defaults[sec.key] = { enabled: true } - for (const mod of sec.modules) defaults[sec.key][mod.key] = true - } - setConfig(defaults) + setConfig(buildDefaultSidebarConfig(sectionDefs)) toast.success(t('Reset to default configuration')) } diff --git a/web/default/src/features/profile/components/tabs/account-bindings-tab.tsx b/web/default/src/features/profile/components/tabs/account-bindings-tab.tsx index d806b4a1..182fa1b2 100644 --- a/web/default/src/features/profile/components/tabs/account-bindings-tab.tsx +++ b/web/default/src/features/profile/components/tabs/account-bindings-tab.tsx @@ -31,6 +31,8 @@ import { type CustomOAuthBinding, } from '@/lib/oauth' import { useDialogs } from '@/hooks/use-dialog' +import { useReadScope } from '@/hooks/use-read-scope' +import { useResettableState } from '@/hooks/use-resettable-state' import { useStatus } from '@/hooks/use-status' import { Button } from '@/components/ui/button' import { Separator } from '@/components/ui/separator' @@ -61,7 +63,10 @@ export function AccountBindingsTab({ const { t } = useTranslation() const dialogs = useDialogs() const { status, loading } = useStatus() - const [customBindings, setCustomBindings] = useState([]) + const beginRead = useReadScope(profile?.id) + const [customBindings, setCustomBindings] = useResettableState< + CustomOAuthBinding[] + >([], profile?.id) const [unbindTarget, setUnbindTarget] = useState( null ) @@ -75,17 +80,19 @@ export function AccountBindingsTab({ | Array<{ id: number; name: string }> | undefined - const fetchCustomBindings = useCallback(async () => { - if (!customProviders || customProviders.length === 0) return - try { - const res = await getSelfOAuthBindings() - if (res.success && res.data) { - setCustomBindings(res.data) - } - } catch { - // ignore - } - }, [customProviders]) + const fetchCustomBindings = useCallback(() => { + if (!customProviders || customProviders.length === 0) + return Promise.resolve() + const current = beginRead() + if (!current()) return Promise.resolve() + return getSelfOAuthBindings() + .then((res) => { + if (current() && res.success && res.data) setCustomBindings(res.data) + }) + .catch(() => { + /* retain the last successful read */ + }) + }, [customProviders, beginRead, setCustomBindings]) useEffect(() => { fetchCustomBindings() @@ -117,7 +124,9 @@ export function AccountBindingsTab({ const handleBindCustomOAuth = (provider: { id: number; name: string }) => { const redirectUrl = `${window.location.origin}/oauth/${provider.id}?bind=true` - window.location.href = `/api/oauth/${provider.id}?redirect=${encodeURIComponent(redirectUrl)}` + window.location.assign( + `/api/oauth/${provider.id}?redirect=${encodeURIComponent(redirectUrl)}` + ) } useEffect(() => { diff --git a/web/default/src/features/profile/components/tabs/notification-tab.tsx b/web/default/src/features/profile/components/tabs/notification-tab.tsx index 02190504..7e192771 100644 --- a/web/default/src/features/profile/components/tabs/notification-tab.tsx +++ b/web/default/src/features/profile/components/tabs/notification-tab.tsx @@ -16,11 +16,12 @@ along with this program. If not, see . For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues */ -import { useState, useEffect, useCallback } from 'react' +import { useState, useCallback } from 'react' import { Bell, Loader2, Mail, Server, Webhook } from 'lucide-react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import { ROLE } from '@/lib/roles' +import { useResettableState } from '@/hooks/use-resettable-state' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' @@ -55,33 +56,25 @@ export function NotificationTab({ profile, onUpdate }: NotificationTabProps) { const { t } = useTranslation() const isAdmin = (profile?.role ?? 0) >= ROLE.ADMIN const [loading, setLoading] = useState(false) - const [settings, setSettings] = useState({ - notify_type: 'email', - quota_warning_threshold: DEFAULT_QUOTA_WARNING_THRESHOLD, - notification_email: '', - webhook_url: '', - webhook_secret: '', - bark_url: '', - gotify_url: '', - gotify_token: '', - gotify_priority: 5, - accept_unset_model_ratio_model: false, - record_ip_log: false, - upstream_model_update_notify_enabled: false, - }) - - // Update form field helper - const updateField = useCallback( - (field: K, value: UserSettings[K]) => { - setSettings((prev) => ({ ...prev, [field]: value })) - }, - [] - ) - - useEffect(() => { - if (profile?.setting) { + const [settings, setSettings] = useResettableState( + () => { + if (!profile?.setting) + return { + notify_type: 'email', + quota_warning_threshold: DEFAULT_QUOTA_WARNING_THRESHOLD, + notification_email: '', + webhook_url: '', + webhook_secret: '', + bark_url: '', + gotify_url: '', + gotify_token: '', + gotify_priority: 5, + accept_unset_model_ratio_model: false, + record_ip_log: false, + upstream_model_update_notify_enabled: false, + } const parsed = parseUserSettings(profile.setting) - setSettings({ + return { notify_type: parsed.notify_type || 'email', quota_warning_threshold: parsed.quota_warning_threshold ?? DEFAULT_QUOTA_WARNING_THRESHOLD, @@ -97,9 +90,18 @@ export function NotificationTab({ profile, onUpdate }: NotificationTabProps) { record_ip_log: parsed.record_ip_log || false, upstream_model_update_notify_enabled: parsed.upstream_model_update_notify_enabled || false, - }) - } - }, [profile]) + } + }, + JSON.stringify([profile?.id, profile?.setting]) + ) + + // Update form field helper + const updateField = useCallback( + (field: K, value: UserSettings[K]) => { + setSettings((prev) => ({ ...prev, [field]: value })) + }, + [setSettings] + ) const handleSave = async () => { try { diff --git a/web/default/src/features/profile/hooks/use-profile.ts b/web/default/src/features/profile/hooks/use-profile.ts index 2b404068..79062fff 100644 --- a/web/default/src/features/profile/hooks/use-profile.ts +++ b/web/default/src/features/profile/hooks/use-profile.ts @@ -19,6 +19,10 @@ For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API import { useState, useEffect, useCallback } from 'react' import i18next from 'i18next' import { toast } from 'sonner' +import { useAuthStore } from '@/stores/auth-store' +import { getSafeErrorDebugInfo } from '@/lib/handle-server-error' +import { useReadScope } from '@/hooks/use-read-scope' +import { useResettableState } from '@/hooks/use-resettable-state' import { getUserProfile, updateUserProfile, updateUserSettings } from '../api' import type { UserProfile, @@ -31,33 +35,46 @@ import type { // ============================================================================ export function useProfile() { - const [profile, setProfile] = useState(null) - const [loading, setLoading] = useState(true) + const sessionRevision = useAuthStore((state) => state.sessionRevision) + const beginRead = useReadScope(sessionRevision) + const [profile, setProfile] = useResettableState( + null, + sessionRevision + ) + const [loading, setLoading] = useResettableState(true, sessionRevision) const [updating, setUpdating] = useState(false) - // Fetch user profile (with optional silent mode) - const fetchProfile = useCallback(async (silent = false) => { - try { - if (!silent) { - setLoading(true) - } - const response = await getUserProfile() - - if (response.success && response.data) { - setProfile(response.data) - } - } catch (error) { - // eslint-disable-next-line no-console - console.error('Failed to fetch profile:', error) - if (!silent) { - toast.error(i18next.t('Failed to load profile')) - } - } finally { - if (!silent) { - setLoading(false) - } - } - }, []) + const loadProfile = useCallback( + (silent = false) => { + const current = beginRead() + if (!current()) return Promise.resolve() + return getUserProfile() + .then((response) => { + if (current() && response.success && response.data) + setProfile(response.data) + }) + .catch((error) => { + if (!current()) return + // eslint-disable-next-line no-console + console.error( + 'Failed to fetch profile:', + getSafeErrorDebugInfo(error) + ) + if (!silent) toast.error(i18next.t('Failed to load profile')) + }) + .finally(() => { + if (current()) setLoading(false) + }) + }, + [beginRead, setProfile, setLoading] + ) + const fetchProfile = useCallback( + (silent = false) => { + if (!silent) setLoading(true) + return loadProfile(silent) + }, + [loadProfile, setLoading] + ) // Refresh profile silently (without loading state) const refreshProfile = useCallback(async () => { @@ -81,7 +98,7 @@ export function useProfile() { return false } catch (error) { // eslint-disable-next-line no-console - console.error('Failed to update profile:', error) + console.error('Failed to update profile:', getSafeErrorDebugInfo(error)) toast.error(i18next.t('Failed to update profile')) return false } finally { @@ -108,7 +125,10 @@ export function useProfile() { return false } catch (error) { // eslint-disable-next-line no-console - console.error('Failed to update settings:', error) + console.error( + 'Failed to update settings:', + getSafeErrorDebugInfo(error) + ) toast.error(i18next.t('Failed to update settings')) return false } finally { @@ -120,8 +140,8 @@ export function useProfile() { // Initial fetch useEffect(() => { - fetchProfile() - }, [fetchProfile]) + void loadProfile() + }, [loadProfile]) return { profile, diff --git a/web/default/src/features/profile/hooks/use-two-fa.ts b/web/default/src/features/profile/hooks/use-two-fa.ts index cabd66d7..623592f9 100644 --- a/web/default/src/features/profile/hooks/use-two-fa.ts +++ b/web/default/src/features/profile/hooks/use-two-fa.ts @@ -16,8 +16,12 @@ along with this program. If not, see . For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues */ -import { useState, useEffect, useCallback } from 'react' +import { useEffect, useCallback } from 'react' +import { useAuthStore } from '@/stores/auth-store' import { get2FAStatus } from '@/lib/api' +import { getSafeErrorDebugInfo } from '@/lib/handle-server-error' +import { useReadScope } from '@/hooks/use-read-scope' +import { useResettableState } from '@/hooks/use-resettable-state' import type { TwoFAStatus } from '../types' // ============================================================================ @@ -31,29 +35,44 @@ const DEFAULT_STATUS: TwoFAStatus = { } export function useTwoFA(enabled = true) { - const [loading, setLoading] = useState(true) - const [status, setStatus] = useState(DEFAULT_STATUS) - - const fetchStatus = useCallback(async () => { - if (!enabled) return - - try { - setLoading(true) - const response = await get2FAStatus() - if (response.success && response.data) { - setStatus(response.data) - } - } catch (error) { - // eslint-disable-next-line no-console - console.error('Failed to fetch 2FA status:', error) - } finally { - setLoading(false) - } - }, [enabled]) + const sessionRevision = useAuthStore((state) => state.sessionRevision) + const scopeKey = JSON.stringify([sessionRevision, enabled]) + const beginRead = useReadScope(scopeKey) + const [loading, setLoading] = useResettableState(enabled, scopeKey) + const [status, setStatus] = useResettableState( + DEFAULT_STATUS, + scopeKey + ) + const loadStatus = useCallback(() => { + if (!enabled) return Promise.resolve() + const current = beginRead() + if (!current()) return Promise.resolve() + return get2FAStatus() + .then((response) => { + if (current() && response.success && response.data) + setStatus(response.data) + }) + .catch((error) => { + if (!current()) return + // eslint-disable-next-line no-console + console.error( + 'Failed to fetch 2FA status:', + getSafeErrorDebugInfo(error) + ) + }) + .finally(() => { + if (current()) setLoading(false) + }) + }, [enabled, beginRead, setStatus, setLoading]) + const fetchStatus = useCallback(() => { + if (!enabled) return Promise.resolve() + setLoading(true) + return loadStatus() + }, [enabled, loadStatus, setLoading]) useEffect(() => { - fetchStatus() - }, [fetchStatus]) + void loadStatus() + }, [loadStatus]) return { status, diff --git a/web/default/src/features/smart-ops/alerts.test.tsx b/web/default/src/features/smart-ops/alerts.test.tsx index 30e5f65e..5d37afa2 100644 --- a/web/default/src/features/smart-ops/alerts.test.tsx +++ b/web/default/src/features/smart-ops/alerts.test.tsx @@ -133,6 +133,7 @@ function ManualSettlementEvidenceHarness(): ReactElement { return ( <> + @@ -662,22 +663,31 @@ describe('SmartOps active alerts', () => { ) try { - await waitFor(() => { - assert.ok( - within(view.container).getByRole('button', { - name: 'Enter exact quota', - }) - ) + const { exactQuotaButton, causeErrorButton } = await waitFor(() => { + // Resolve both controls by role and accessible name in one scan. + const names = new Map() + const buttons = within(view.container).getAllByRole('button', { + name: (name: string, element: Element): boolean => { + names.set(element, name) + return true + }, + }) + const namedButton = (name: string): HTMLElement => { + const matches = buttons.filter((button) => names.get(button) === name) + assert.equal( + matches.length, + 1, + `expected one accessible ${name} button` + ) + return matches[0] + } + return { + exactQuotaButton: namedButton('Enter exact quota'), + causeErrorButton: namedButton('Cause reconciliation error'), + } }) - const causeErrorButton = view.container.querySelector( - 'button' - ) as HTMLButtonElement assert.equal(causeErrorButton.textContent, 'Cause reconciliation error') - await view.click( - within(view.container).getByRole('button', { - name: 'Enter exact quota', - }) - ) + await view.click(exactQuotaButton) const dialog = await waitFor(() => { const currentDialog = document.body.querySelector( @@ -748,6 +758,9 @@ describe('SmartOps active alerts', () => { ) try { + const causeErrorButton = within(view.container).getByRole('button', { + name: 'Cause reconciliation error', + }) await view.click( await waitFor(() => within(view.container).getByRole('checkbox', { @@ -785,9 +798,6 @@ describe('SmartOps active alerts', () => { return button }) - const causeErrorButton = view.container.querySelector( - 'button' - ) as HTMLButtonElement assert.equal(causeErrorButton.textContent, 'Cause reconciliation error') await view.click(causeErrorButton) await waitFor(() => { @@ -1188,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', { @@ -1203,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', }) ) @@ -1256,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', }) ) @@ -1276,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 @@ -1300,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, @@ -1308,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/smart-ops/channel-health/index.runtime.test.tsx b/web/default/src/features/smart-ops/channel-health/index.runtime.test.tsx new file mode 100644 index 00000000..629573e9 --- /dev/null +++ b/web/default/src/features/smart-ops/channel-health/index.runtime.test.tsx @@ -0,0 +1,87 @@ +/* +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 } from 'react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { createReactTestEnvironment } from '@/test/react' +import { waitFor } from '@testing-library/react' +import assert from 'node:assert/strict' +import { after, test } from 'node:test' +import { api } from '@/lib/api' + +const errorKey = 'We could not load the channel health policy.' +const frenchError = 'Impossible de charger la politique des canaux.' +const testEnv = createReactTestEnvironment({ + lng: 'fr', + fallbackLng: 'en', + resources: { + en: { translation: { [errorKey]: errorKey } }, + fr: { translation: { [errorKey]: frenchError } }, + }, +}) +await testEnv.setup() +const { ChannelHealth } = await import('./index') +after(() => testEnv.teardown()) + +test('policy errors translate without changing the data cache or repeating the policy request', async () => { + const originalAdapter = api.defaults.adapter + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }) + let policyRequests = 0 + api.defaults.adapter = async (config) => { + assert.equal(config.method, 'get') + const policy = config.url === '/api/smart-ops/channel-health/policy' + if (policy) policyRequests += 1 + return { + data: { success: true, data: [] }, + config, + status: 200, + statusText: 'OK', + headers: {}, + } + } + const queryKey = ['smart-ops', 'channel-health-policy'] + const view = await testEnv.render( + + + + ) + try { + await waitFor(() => { + assert.equal(client.getQueryState(queryKey)?.status, 'error') + assert.ok(view.container.textContent?.includes(frenchError)) + }) + const cachedError = client.getQueryState(queryKey)?.error + assert.ok(cachedError instanceof Error) + assert.equal(cachedError.message, errorKey) + await act(async () => { + await testEnv.i18n.changeLanguage('en') + }) + await waitFor(() => { + assert.ok(view.container.textContent?.includes(errorKey)) + }) + assert.equal(client.getQueryState(queryKey)?.error, cachedError) + assert.equal(policyRequests, 1) + assert.equal(client.getQueryCache().findAll({ queryKey }).length, 1) + } finally { + await view.unmount() + client.clear() + api.defaults.adapter = originalAdapter + } +}) diff --git a/web/default/src/features/smart-ops/channel-health/index.tsx b/web/default/src/features/smart-ops/channel-health/index.tsx index 8cab67fe..aad66e57 100644 --- a/web/default/src/features/smart-ops/channel-health/index.tsx +++ b/web/default/src/features/smart-ops/channel-health/index.tsx @@ -397,7 +397,7 @@ export function ChannelHealth(): ReactElement { ) if (!hasAllOptions) { throw new Error( - response.message || t('We could not load the channel health policy.') + response.message || 'We could not load the channel health policy.' ) } return response diff --git a/web/default/src/features/subscriptions/components/dialogs/subscription-purchase-dialog.tsx b/web/default/src/features/subscriptions/components/dialogs/subscription-purchase-dialog.tsx index f67da938..7afdcf8f 100644 --- a/web/default/src/features/subscriptions/components/dialogs/subscription-purchase-dialog.tsx +++ b/web/default/src/features/subscriptions/components/dialogs/subscription-purchase-dialog.tsx @@ -16,12 +16,13 @@ along with this program. If not, see . For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues */ -import { useState, useEffect } from 'react' +import { useState } from 'react' import { Crown, CalendarClock, Package } from 'lucide-react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import { DEFAULT_CURRENCY_CONFIG } from '@/stores/system-config-store' import { formatQuota } from '@/lib/format' +import { useResettableState } from '@/hooks/use-resettable-state' import { useSystemConfig } from '@/hooks/use-system-config' import { Alert, AlertDescription } from '@/components/ui/alert' import { Button } from '@/components/ui/button' @@ -75,15 +76,14 @@ export function SubscriptionPurchaseDialog(props: Props) { const { t } = useTranslation() const { currency } = useSystemConfig() const [paying, setPaying] = useState(false) - const [selectedEpayMethod, setSelectedEpayMethod] = useState('') - - useEffect(() => { - if (props.open && props.epayMethods && props.epayMethods.length > 0) { - setSelectedEpayMethod(props.epayMethods[0].type) - } else if (!props.open) { - setSelectedEpayMethod('') - } - }, [props.open, props.epayMethods]) + const [selectedEpayMethod, setSelectedEpayMethod] = useResettableState( + props.open ? props.epayMethods?.[0]?.type || '' : '', + JSON.stringify([ + props.open, + props.plan?.plan.id, + props.epayMethods?.map((method) => method.type), + ]) + ) const plan = props.plan?.plan if (!plan) return null diff --git a/web/default/src/features/system-settings/auth/passkey-section.tsx b/web/default/src/features/system-settings/auth/passkey-section.tsx index fdd54ccf..63b88c53 100644 --- a/web/default/src/features/system-settings/auth/passkey-section.tsx +++ b/web/default/src/features/system-settings/auth/passkey-section.tsx @@ -187,9 +187,11 @@ export function PasskeySection(props: PasskeySectionProps) { return (
- + void form.handleSubmit(onSubmit)(event)} + > void form.handleSubmit(onSubmit)()} isSaving={updateOption.isPending} /> . 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 } @@ -111,7 +95,10 @@ type SettingsPageFormActionsProps = { saveButtonRef?: RefObject } -export function SettingsPageFormActions(props: SettingsPageFormActionsProps) { +export function SettingsPageFormActions({ + saveButtonRef, + ...props +}: SettingsPageFormActionsProps) { const { t } = useTranslation() const saveLabel = props.isSaving ? (props.savingLabel ?? 'Saving...') @@ -132,7 +119,7 @@ export function SettingsPageFormActions(props: SettingsPageFormActionsProps) { )} + {}} /> + + ) + } + const view = await env.render() + const body = within(env.dom.window.document.body) + try { + await waitFor(() => assert.ok(pending.has('1'))) + await view.click(within(view.container).getByText('Select another user')) + await waitFor(() => assert.ok(pending.has('2'))) + await act(async () => pending.get('2')!()) + assert.ok(body.queryByText('user-2')) + await act(async () => pending.get('1')!()) + assert.ok(body.queryByText('user-2')) + assert.equal(body.queryByText('user-1'), null) + await act(async () => { + await env.i18n.changeLanguage('fr') + }) + assert.deepEqual(requests, ['1', '2']) + } finally { + for (const resolve of pending.values()) resolve() + await view.unmount() + api.defaults.adapter = adapter + } +}) 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..dcec83d0 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 @@ -16,11 +16,13 @@ along with this program. If not, see . For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues */ -import { useCallback, useEffect, useState } from 'react' +import { useEffect, useEffectEvent } from 'react' import { Loader2 } from 'lucide-react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import { formatQuota, formatCompactNumber } from '@/lib/format' +import { useReadScope } from '@/hooks/use-read-scope' +import { useResettableState } from '@/hooks/use-resettable-state' import { Dialog, DialogContent, @@ -38,54 +40,55 @@ 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, onOpenChange, }: UserInfoDialogProps) { const { t } = useTranslation() - const [userInfo, setUserInfo] = useState(null) - const [isLoading, setIsLoading] = useState(false) - - const fetchUserInfo = useCallback( - async (id: number) => { - setIsLoading(true) - try { - const result = await getUserInfo(id) - if (result.success) { - setUserInfo(result.data || null) - } else { - toast.error(result.message || t('Failed to fetch user information')) - } - } catch (error) { - // eslint-disable-next-line no-console - console.error('Failed to fetch user info:', error) - toast.error(t('Failed to fetch user information')) - } finally { - setIsLoading(false) - } - }, - [t] + const scopeKey = JSON.stringify([open, userId]) + const beginRead = useReadScope(scopeKey) + const [userInfo, setUserInfo] = useResettableState( + null, + beginRead ) - - useEffect(() => { - if (open && userId) { - fetchUserInfo(userId) - } - }, [open, userId, fetchUserInfo]) - - const InfoItem = ({ - label, - value, - }: { - label: string - value: string | number - }) => ( -
- -
{value}
-
+ const [isLoading, setIsLoading] = useResettableState( + Boolean(open && userId), + beginRead ) + const reportFailure = useEffectEvent((message?: string) => + toast.error(message || t('Failed to fetch user information')) + ) + useEffect(() => { + if (!open || !userId) return + const current = beginRead() + void getUserInfo(userId) + .then((result) => { + if (!current()) return + if (result.success) setUserInfo(result.data || null) + else reportFailure(result.message) + }) + .catch(() => { + if (current()) reportFailure() + }) + .finally(() => { + if (current()) setIsLoading(false) + }) + }, [open, userId, beginRead, setUserInfo, setIsLoading]) 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/src/features/users/components/dialogs/user-binding-dialog.tsx b/web/default/src/features/users/components/dialogs/user-binding-dialog.tsx index dbdc4ef5..ad721803 100644 --- a/web/default/src/features/users/components/dialogs/user-binding-dialog.tsx +++ b/web/default/src/features/users/components/dialogs/user-binding-dialog.tsx @@ -16,7 +16,13 @@ along with this program. If not, see . For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues */ -import { useState, useEffect, useCallback, useMemo } from 'react' +import { + useState, + useEffect, + useEffectEvent, + useCallback, + useMemo, +} from 'react' import { Mail, Globe, @@ -32,6 +38,8 @@ import { useTranslation } from 'react-i18next' import { SiGithub, SiDiscord } from 'react-icons/si' import { toast } from 'sonner' import { api } from '@/lib/api' +import { useReadScope } from '@/hooks/use-read-scope' +import { useResettableState } from '@/hooks/use-resettable-state' import { Button } from '@/components/ui/button' import { Dialog, @@ -165,58 +173,79 @@ function CustomProviderIcon(props: { iconUrl?: string }) { export function UserBindingDialog(props: Props) { const { t } = useTranslation() - const [user, setUser] = useState(null) - const [oauthBindings, setOauthBindings] = useState([]) - const [statusInfo, setStatusInfo] = useState({}) - const [loading, setLoading] = useState(false) - const [showBoundOnly, setShowBoundOnly] = useState(true) - const [unbindTarget, setUnbindTarget] = useState(null) + const beginRead = useReadScope(JSON.stringify([props.open, props.userId])) + const [user, setUser] = useResettableState(null, beginRead) + const [oauthBindings, setOauthBindings] = useResettableState( + [], + beginRead + ) + const [statusInfo, setStatusInfo] = useResettableState( + {}, + beginRead + ) + const [loading, setLoading] = useResettableState( + Boolean(props.open && props.userId), + beginRead + ) + const [showBoundOnly, setShowBoundOnly] = useResettableState(true, beginRead) + const [unbindTarget, setUnbindTarget] = + useResettableState(null, beginRead) const [unbinding, setUnbinding] = useState(false) - const fetchData = useCallback(async () => { - if (!props.userId) return - setLoading(true) - try { - const [userRes, oauthRes, statusRes] = await Promise.all([ - getUser(props.userId), - getUserOAuthBindings(props.userId).catch(() => ({ + const loadData = useCallback(() => { + if (!props.userId) return Promise.resolve() + const current = beginRead() + if (!current()) return Promise.resolve() + return Promise.all([ + getUser(props.userId), + getUserOAuthBindings(props.userId).catch(() => ({ + success: false, + data: [], + })), + api + .get('/api/status') + .then((r) => r.data) + .catch(() => ({ success: false, - data: [], + data: {}, })), - api - .get('/api/status') - .then((r) => r.data) - .catch(() => ({ - success: false, - data: {}, - })), - ]) - if (userRes.success && userRes.data) { - setUser(userRes.data) - } - if (oauthRes.success && oauthRes.data) { - setOauthBindings(oauthRes.data as OAuthBinding[]) - } - if (statusRes.success && statusRes.data) { - setStatusInfo(statusRes.data as StatusInfo) - } - } catch { - toast.error(t('Failed to load')) - } finally { - setLoading(false) - } - }, [props.userId, t]) + ]) + .then(([userRes, oauthRes, statusRes]) => { + if (!current()) return + if (userRes.success && userRes.data) { + setUser(userRes.data) + } + if (oauthRes.success && oauthRes.data) { + setOauthBindings(oauthRes.data as OAuthBinding[]) + } + if (statusRes.success && statusRes.data) { + setStatusInfo(statusRes.data as StatusInfo) + } + }) + .catch(() => { + if (current()) toast.error(t('Failed to load')) + }) + .finally(() => { + if (current()) setLoading(false) + }) + }, [ + props.userId, + beginRead, + setUser, + setOauthBindings, + setStatusInfo, + setLoading, + t, + ]) + const fetchData = useCallback(() => { + setLoading(true) + return loadData() + }, [loadData, setLoading]) + const readInitialData = useEffectEvent(() => loadData()) useEffect(() => { - if (props.open && props.userId) { - setShowBoundOnly(true) - fetchData() - } else { - setUser(null) - setOauthBindings([]) - setStatusInfo({}) - } - }, [props.open, props.userId, fetchData]) + if (props.open && props.userId) void readInitialData() + }, [props.open, props.userId, beginRead]) const allBindings = useMemo(() => { const items: BindingItem[] = [] diff --git a/web/default/src/features/wallet/components/recharge-form-card.tsx b/web/default/src/features/wallet/components/recharge-form-card.tsx index 351efe54..7bfac7e6 100644 --- a/web/default/src/features/wallet/components/recharge-form-card.tsx +++ b/web/default/src/features/wallet/components/recharge-form-card.tsx @@ -16,11 +16,11 @@ along with this program. If not, see . For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues */ -import { useState, useEffect } from 'react' import { Gift, ExternalLink, Loader2, Receipt, WalletCards } from 'lucide-react' import { useTranslation } from 'react-i18next' import { formatNumber } from '@/lib/format' import { cn } from '@/lib/utils' +import { useResettableState } from '@/hooks/use-resettable-state' import { Alert, AlertDescription } from '@/components/ui/alert' import { Button } from '@/components/ui/button' import { Card, CardContent, CardHeader } from '@/components/ui/card' @@ -110,15 +110,18 @@ export function RechargeFormCard({ enableWaffoPancakeTopup, }: RechargeFormCardProps) { const { t } = useTranslation() - const [localAmount, setLocalAmount] = useState(topupAmount.toString()) - - useEffect(() => { - setLocalAmount(topupAmount.toString()) - }, [topupAmount]) + const [amountDraft, setAmountDraft] = useResettableState( + () => ({ source: topupAmount, value: topupAmount.toString() }), + 'recharge-amount' + ) + if (amountDraft.source !== topupAmount) { + setAmountDraft({ source: topupAmount, value: topupAmount.toString() }) + } + const localAmount = amountDraft.value const handleAmountChange = (value: string) => { - setLocalAmount(value) const numValue = parseInt(value) || 0 + setAmountDraft({ source: numValue, value }) if (numValue >= 0) { onTopupAmountChange(numValue) } diff --git a/web/default/src/features/wallet/components/subscription-plans-card.tsx b/web/default/src/features/wallet/components/subscription-plans-card.tsx index d3cc0315..83f712d0 100644 --- a/web/default/src/features/wallet/components/subscription-plans-card.tsx +++ b/web/default/src/features/wallet/components/subscription-plans-card.tsx @@ -20,8 +20,11 @@ import { useState, useEffect, useMemo, useCallback } from 'react' import { Crown, RefreshCw, Sparkles, Check } from 'lucide-react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' +import { useAuthStore } from '@/stores/auth-store' import { formatQuota } from '@/lib/format' import { cn } from '@/lib/utils' +import { useReadScope } from '@/hooks/use-read-scope' +import { useResettableState } from '@/hooks/use-resettable-state' import { Button } from '@/components/ui/button' import { Card, CardContent, CardHeader } from '@/components/ui/card' import { Progress } from '@/components/ui/progress' @@ -97,18 +100,30 @@ export function SubscriptionPlansCard({ onPurchaseSuccess, }: SubscriptionPlansCardProps) { const { t } = useTranslation() + const sessionRevision = useAuthStore((state) => state.sessionRevision) + const beginPlansRead = useReadScope(sessionRevision) + const beginSubscriptionRead = useReadScope(sessionRevision) - const [plans, setPlans] = useState([]) - const [activeSubscriptions, setActiveSubscriptions] = useState< + const [plans, setPlans] = useResettableState( + [], + sessionRevision + ) + const [activeSubscriptions, setActiveSubscriptions] = useResettableState< UserSubscriptionRecord[] - >([]) - const [allSubscriptions, setAllSubscriptions] = useState< + >([], sessionRevision) + const [allSubscriptions, setAllSubscriptions] = useResettableState< UserSubscriptionRecord[] - >([]) - const [billingPreference, setBillingPreference] = - useState('subscription_first') - const [loading, setLoading] = useState(true) + >([], sessionRevision) + const [billingPreference, setBillingPreference] = useResettableState( + 'subscription_first', + sessionRevision + ) + const [loading, setLoading] = useResettableState(true, sessionRevision) const [refreshing, setRefreshing] = useState(false) + const [now, setNow] = useResettableState( + () => Date.now() / 1000, + sessionRevision + ) const [purchaseOpen, setPurchaseOpen] = useState(false) const [selectedPlan, setSelectedPlan] = useState(null) @@ -122,40 +137,73 @@ export function SubscriptionPlansCard({ [topupInfo?.pay_methods] ) - const fetchPlans = useCallback(async () => { - try { - const res = await getPublicPlans() - if (res.success) { - setPlans(res.data || []) - } - } catch { - setPlans([]) - } - }, []) + const fetchPlans = useCallback(() => { + const current = beginPlansRead() + if (!current()) return Promise.resolve() + return getPublicPlans() + .then((res) => { + if (!current()) return + if (res.success) setPlans(res.data || []) + }) + .catch(() => { + if (current()) setPlans([]) + }) + }, [beginPlansRead, setPlans]) + + const fetchSelfSubscription = useCallback(() => { + const current = beginSubscriptionRead() + if (!current()) return Promise.resolve() + return getSelfSubscriptionFull() + .then((res) => { + if (!current()) return + if (res.success && res.data) { + setBillingPreference( + res.data.billing_preference || 'subscription_first' + ) + setActiveSubscriptions(res.data.subscriptions || []) + setAllSubscriptions(res.data.all_subscriptions || []) + setNow(Date.now() / 1000) + } + }) + .catch(() => { + /* retain the last successful read */ + }) + }, [ + beginSubscriptionRead, + setActiveSubscriptions, + setAllSubscriptions, + setBillingPreference, + setNow, + ]) - const fetchSelfSubscription = useCallback(async () => { - try { - const res = await getSelfSubscriptionFull() - if (res.success && res.data) { - setBillingPreference( - res.data.billing_preference || 'subscription_first' - ) - setActiveSubscriptions(res.data.subscriptions || []) - setAllSubscriptions(res.data.all_subscriptions || []) - } - } catch { - // ignore + useEffect(() => { + let current = true + void Promise.all([fetchPlans(), fetchSelfSubscription()]).then(() => { + if (current) setLoading(false) + }) + return () => { + current = false } - }, []) - + }, [fetchPlans, fetchSelfSubscription, setLoading]) useEffect(() => { - const init = async () => { - setLoading(true) - await Promise.all([fetchPlans(), fetchSelfSubscription()]) - setLoading(false) + const current = Date.now() + const day = 86_400_000 + let next = Infinity + for (const { subscription } of allSubscriptions) { + const remaining = (subscription.end_time || 0) * 1000 - current + if ( + subscription.status !== 'active' || + !Number.isFinite(remaining) || + remaining < 0 + ) + continue + const boundary = remaining % day || (remaining === 0 ? 0 : day) + next = Math.min(next, boundary + 1) } - init() - }, [fetchPlans, fetchSelfSubscription]) + if (!Number.isFinite(next)) return + const timer = setTimeout(() => setNow(Date.now() / 1000), next) + return () => clearTimeout(timer) + }, [allSubscriptions, now, setNow]) const handleRefresh = async () => { setRefreshing(true) @@ -222,7 +270,6 @@ export function SubscriptionPlansCard({ const getRemainingDays = (sub: UserSubscriptionRecord) => { const endTime = sub?.subscription?.end_time || 0 if (!endTime) return 0 - const now = Date.now() / 1000 return Math.max(0, Math.ceil((endTime - now) / 86400)) } @@ -403,7 +450,6 @@ export function SubscriptionPlansCard({ planTitleMap.get(subscription?.plan_id) || '' const remainDays = getRemainingDays(sub) const usagePercent = getUsagePercent(sub) - const now = Date.now() / 1000 const isExpired = (subscription?.end_time || 0) < now const isCancelled = subscription?.status === 'cancelled' const isActive = diff --git a/web/default/src/features/wallet/hooks/use-affiliate.ts b/web/default/src/features/wallet/hooks/use-affiliate.ts index 0876c9d0..90d1e7e6 100644 --- a/web/default/src/features/wallet/hooks/use-affiliate.ts +++ b/web/default/src/features/wallet/hooks/use-affiliate.ts @@ -19,8 +19,12 @@ For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API import { useState, useEffect, useCallback } from 'react' import i18next from 'i18next' import { toast } from 'sonner' +import { useAuthStore } from '@/stores/auth-store' import { getSelf } from '@/lib/api' +import { getSafeErrorDebugInfo } from '@/lib/handle-server-error' import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard' +import { useReadScope } from '@/hooks/use-read-scope' +import { useResettableState } from '@/hooks/use-resettable-state' import { getAffiliateCode, transferAffiliateQuota } from '../api' import { generateAffiliateLink } from '../lib' @@ -29,30 +33,46 @@ import { generateAffiliateLink } from '../lib' // ============================================================================ export function useAffiliate() { - const [affiliateCode, setAffiliateCode] = useState('') - const [affiliateLink, setAffiliateLink] = useState('') - const [loading, setLoading] = useState(true) + const sessionRevision = useAuthStore((state) => state.sessionRevision) + const beginRead = useReadScope(sessionRevision) + const [affiliateCode, setAffiliateCode] = useResettableState( + '', + sessionRevision + ) + const [affiliateLink, setAffiliateLink] = useResettableState( + '', + sessionRevision + ) + const [loading, setLoading] = useResettableState(true, sessionRevision) const [transferring, setTransferring] = useState(false) const { copyToClipboard } = useCopyToClipboard() - // Fetch affiliate code - const fetchAffiliateCode = useCallback(async () => { - try { - setLoading(true) - const response = await getAffiliateCode() - - if (response.success && response.data) { - setAffiliateCode(response.data) - const link = generateAffiliateLink(response.data) - setAffiliateLink(link) - } - } catch (error) { - // eslint-disable-next-line no-console - console.error('Failed to fetch affiliate code:', error) - } finally { - setLoading(false) - } - }, []) + const loadAffiliateCode = useCallback(() => { + const current = beginRead() + if (!current()) return Promise.resolve() + return getAffiliateCode() + .then((response) => { + if (current() && response.success && response.data) { + setAffiliateCode(response.data) + setAffiliateLink(generateAffiliateLink(response.data)) + } + }) + .catch((error) => { + if (!current()) return + // eslint-disable-next-line no-console + console.error( + 'Failed to fetch affiliate code:', + getSafeErrorDebugInfo(error) + ) + }) + .finally(() => { + if (current()) setLoading(false) + }) + }, [beginRead, setAffiliateCode, setAffiliateLink, setLoading]) + const fetchAffiliateCode = useCallback(() => { + setLoading(true) + return loadAffiliateCode() + }, [loadAffiliateCode, setLoading]) // Copy affiliate link const copyAffiliateLink = useCallback(() => { @@ -82,8 +102,8 @@ export function useAffiliate() { }, []) useEffect(() => { - fetchAffiliateCode() - }, [fetchAffiliateCode]) + void loadAffiliateCode() + }, [loadAffiliateCode]) return { affiliateCode, diff --git a/web/default/src/features/wallet/hooks/use-topup-info.ts b/web/default/src/features/wallet/hooks/use-topup-info.ts index 42e13b80..fc119401 100644 --- a/web/default/src/features/wallet/hooks/use-topup-info.ts +++ b/web/default/src/features/wallet/hooks/use-topup-info.ts @@ -16,7 +16,11 @@ along with this program. If not, see . For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues */ -import { useState, useEffect } from 'react' +import { useCallback, useEffect } from 'react' +import { useAuthStore } from '@/stores/auth-store' +import { getSafeErrorDebugInfo } from '@/lib/handle-server-error' +import { useReadScope } from '@/hooks/use-read-scope' +import { useResettableState } from '@/hooks/use-resettable-state' import { getTopupInfo } from '../api' import { generatePresetAmounts, @@ -162,60 +166,75 @@ function parseDiscountMap(data: unknown): Record { } export function useTopupInfo() { - const [topupInfo, setTopupInfo] = useState(null) - const [presetAmounts, setPresetAmounts] = useState([]) - const [loading, setLoading] = useState(true) - - const fetchTopupInfo = async () => { - try { - setLoading(true) - - const response = await getTopupInfo() - - if (!response.success || !response.data) { + const sessionRevision = useAuthStore((state) => state.sessionRevision) + const beginRead = useReadScope(sessionRevision) + const [topupInfo, setTopupInfo] = useResettableState( + null, + sessionRevision + ) + const [presetAmounts, setPresetAmounts] = useResettableState( + [], + sessionRevision + ) + const [loading, setLoading] = useResettableState(true, sessionRevision) + + const loadTopupInfo = useCallback(() => { + const current = beginRead() + if (!current()) return Promise.resolve() + return getTopupInfo() + .then((response) => { + if (!current()) return + + if (!response.success || !response.data) { + // eslint-disable-next-line no-console + console.error('Failed to fetch topup info') + return + } + + const processedData: TopupInfo = { + ...response.data, + pay_methods: parsePaymentMethods( + response.data.pay_methods, + response.data.stripe_min_topup + ), + amount_options: parseAmountOptions(response.data.amount_options), + discount: parseDiscountMap(response.data.discount), + creem_products: parseCreemProducts(response.data.creem_products), + waffo_pay_methods: parseWaffoPayMethods( + response.data.waffo_pay_methods + ), + } + + setTopupInfo(processedData) + + if (processedData.amount_options.length > 0) { + const customPresets = mergePresetAmounts( + processedData.amount_options, + processedData.discount || {} + ) + setPresetAmounts(customPresets) + } else { + const minTopup = getMinTopupAmount(processedData) + const defaultPresets = generatePresetAmounts(minTopup) + setPresetAmounts(defaultPresets) + } + }) + .catch((err) => { + if (!current()) return // eslint-disable-next-line no-console - console.error('Failed to fetch topup info:', response.message) - return - } - - const processedData: TopupInfo = { - ...response.data, - pay_methods: parsePaymentMethods( - response.data.pay_methods, - response.data.stripe_min_topup - ), - amount_options: parseAmountOptions(response.data.amount_options), - discount: parseDiscountMap(response.data.discount), - creem_products: parseCreemProducts(response.data.creem_products), - waffo_pay_methods: parseWaffoPayMethods( - response.data.waffo_pay_methods - ), - } - - setTopupInfo(processedData) - - if (processedData.amount_options.length > 0) { - const customPresets = mergePresetAmounts( - processedData.amount_options, - processedData.discount || {} - ) - setPresetAmounts(customPresets) - } else { - const minTopup = getMinTopupAmount(processedData) - const defaultPresets = generatePresetAmounts(minTopup) - setPresetAmounts(defaultPresets) - } - } catch (err) { - // eslint-disable-next-line no-console - console.error('Failed to fetch topup info:', err) - } finally { - setLoading(false) - } - } - + console.error('Failed to fetch topup info:', getSafeErrorDebugInfo(err)) + }) + .finally(() => { + if (current()) setLoading(false) + }) + }, [beginRead, setTopupInfo, setPresetAmounts, setLoading]) + const fetchTopupInfo = useCallback(() => { + setLoading(true) + return loadTopupInfo() + }, [loadTopupInfo, setLoading]) useEffect(() => { - fetchTopupInfo() - }, []) + void loadTopupInfo() + }, [loadTopupInfo]) return { topupInfo, diff --git a/web/default/src/features/wallet/index.tsx b/web/default/src/features/wallet/index.tsx index d44503a6..f9980a3d 100644 --- a/web/default/src/features/wallet/index.tsx +++ b/web/default/src/features/wallet/index.tsx @@ -18,7 +18,11 @@ For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API */ import { useState, useEffect, useCallback, useMemo } from 'react' import { useTranslation } from 'react-i18next' +import { useAuthStore } from '@/stores/auth-store' import { getSelf } from '@/lib/api' +import { getSafeErrorDebugInfo } from '@/lib/handle-server-error' +import { useReadScope } from '@/hooks/use-read-scope' +import { useResettableState } from '@/hooks/use-resettable-state' import { useStatus } from '@/hooks/use-status' import { useSystemConfig } from '@/hooks/use-system-config' import { SectionPageLayout } from '@/components/layout' @@ -58,16 +62,36 @@ interface WalletProps { export function Wallet(props: WalletProps) { const { t } = useTranslation() - const [user, setUser] = useState(null) - const [userLoading, setUserLoading] = useState(true) - const [topupAmount, setTopupAmount] = useState(0) + const sessionRevision = useAuthStore((state) => state.sessionRevision) + const beginRead = useReadScope() + const [user, setUser] = useResettableState( + null, + sessionRevision + ) + const [userLoading, setUserLoading] = useResettableState( + true, + sessionRevision + ) + const [topupAmountDraft, setTopupAmount] = useResettableState( + null, + sessionRevision + ) const [selectedPreset, setSelectedPreset] = useState(null) const [selectedPaymentMethod, setSelectedPaymentMethod] = useState() const [paymentLoading, setPaymentLoading] = useState(null) const [confirmDialogOpen, setConfirmDialogOpen] = useState(false) const [transferDialogOpen, setTransferDialogOpen] = useState(false) - const [billingDialogOpen, setBillingDialogOpen] = useState(false) + const [billingDialogOpen, setBillingDialogOpen] = useState( + Boolean(props.initialShowHistory) + ) + const [previousShowHistory, setPreviousShowHistory] = useState( + props.initialShowHistory + ) + if (previousShowHistory !== props.initialShowHistory) { + setPreviousShowHistory(props.initialShowHistory) + if (props.initialShowHistory) setBillingDialogOpen(true) + } const [redemptionCode, setRedemptionCode] = useState('') const [creemDialogOpen, setCreemDialogOpen] = useState(false) const [selectedCreemProduct, setSelectedCreemProduct] = @@ -79,6 +103,8 @@ export function Wallet(props: WalletProps) { const { topupInfo, presetAmounts, loading: topupLoading } = useTopupInfo() // Calculate effective exchange rate - when display type is USD, use rate of 1 + const topupAmount = + topupAmountDraft ?? (topupInfo ? getMinTopupAmount(topupInfo) : 0) const effectiveUsdExchangeRate = useMemo(() => { return currency?.quotaDisplayType === 'USD' ? 1 @@ -103,44 +129,50 @@ export function Wallet(props: WalletProps) { const { processing: pancakeProcessing, processWaffoPancakePayment } = useWaffoPancakePayment() - // Fetch and refresh user data - const fetchUser = useCallback(async () => { - try { - setUserLoading(true) - const response = await getSelf() - if (response.success && response.data) { - setUser(response.data as UserWalletData) - } - } catch (error) { - // eslint-disable-next-line no-console - console.error('Failed to fetch user data:', error) - } finally { - setUserLoading(false) - } - }, []) - + const loadUser = useCallback(() => { + const current = beginRead() + if (!current()) return Promise.resolve() + return getSelf() + .then((response) => { + if (current() && response.success && response.data) + setUser(response.data as UserWalletData) + }) + .catch((error) => { + if (!current()) return + // eslint-disable-next-line no-console + console.error( + 'Failed to fetch user data:', + getSafeErrorDebugInfo(error) + ) + }) + .finally(() => { + if (current()) setUserLoading(false) + }) + }, [beginRead, setUser, setUserLoading]) + const fetchUser = useCallback(() => { + setUserLoading(true) + return loadUser() + }, [loadUser, setUserLoading]) useEffect(() => { - fetchUser() - }, [fetchUser]) + void loadUser() + }, [loadUser]) useEffect(() => { if (props.initialShowHistory) { - setBillingDialogOpen(true) window.history.replaceState({}, '', window.location.pathname) } }, [props.initialShowHistory]) // Initialize topup amount when topup info is loaded useEffect(() => { - if (topupInfo && topupAmount === 0) { + if (topupInfo && topupAmountDraft === null) { const minTopup = getMinTopupAmount(topupInfo) - setTopupAmount(minTopup) // Calculate initial payment amount with default payment type const defaultPaymentType = getDefaultPaymentType(topupInfo) calculatePaymentAmount(minTopup, defaultPaymentType) } - }, [topupInfo, topupAmount, calculatePaymentAmount]) + }, [topupInfo, topupAmountDraft, calculatePaymentAmount]) // Get current payment type (selected or default) const getCurrentPaymentType = useCallback(() => { diff --git a/web/default/src/features/wallet/wallet-lifecycle.runtime.test.tsx b/web/default/src/features/wallet/wallet-lifecycle.runtime.test.tsx new file mode 100644 index 00000000..33c581cd --- /dev/null +++ b/web/default/src/features/wallet/wallet-lifecycle.runtime.test.tsx @@ -0,0 +1,459 @@ +/* +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, useState } from 'react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { createReactTestEnvironment } from '@/test/react' +import assert from 'node:assert/strict' +import { after, test } from 'node:test' +import { useAuthStore } from '@/stores/auth-store' +import { api } from '@/lib/api' +import { + subscriptionPlanSchema, + type PlanRecord, +} from '@/features/subscriptions/types' + +const env = createReactTestEnvironment() +await env.setup() +const storage = Object.getOwnPropertyDescriptor(globalThis, 'localStorage') +Object.defineProperty(globalThis, 'localStorage', { + configurable: true, + value: env.dom.window.localStorage, +}) +Object.defineProperty(env.dom.window.HTMLElement.prototype, 'scrollIntoView', { + configurable: true, + value() {}, +}) +const { fireEvent, waitFor, within } = + await import('@testing-library/react/pure') +const { Wallet } = await import('./index') +const { SubscriptionPlansCard } = + await import('./components/subscription-plans-card') +const { SubscriptionPurchaseDialog } = + await import('@/features/subscriptions/components/dialogs/subscription-purchase-dialog') +after(() => { + env.teardown() + if (storage) Object.defineProperty(globalThis, 'localStorage', storage) + else Reflect.deleteProperty(globalThis, 'localStorage') +}) + +test('wallet custom zero stays explicit instead of being initialized again', async () => { + const adapter = api.defaults.adapter + const quotes: number[] = [], + mutations: string[] = [] + api.defaults.adapter = async (config) => { + let data: unknown = { success: true, data: {} } + if (config.url === '/api/user/self') + data = { + success: true, + data: { + id: 1, + username: 'synthetic-user', + quota: 0, + used_quota: 0, + request_count: 0, + aff_quota: 0, + }, + } + else if (config.url === '/api/user/topup/info') + data = { + success: true, + data: { + enable_online_topup: true, + min_topup: 10, + pay_methods: [{ name: 'Synthetic method', type: 'alipay' }], + amount_options: [], + discount: {}, + stripe_min_topup: 10, + }, + } + else if (config.url === '/api/user/aff') data = { success: true, data: '' } + else if (config.url === '/api/subscription/plans') + data = { success: true, data: [] } + else if (config.url === '/api/subscription/self') + data = { + success: true, + data: { + billing_preference: 'wallet_first', + subscriptions: [], + all_subscriptions: [], + }, + } + else if (config.url === '/api/user/amount') { + const amount = JSON.parse(config.data).amount + quotes.push(amount) + data = { success: true, message: 'success', data: String(amount) } + } else if (config.method !== 'get') mutations.push(config.url || '') + return { config, status: 200, statusText: 'OK', headers: {}, data } + } + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }) + function Harness() { + const [, setRevision] = useState(0) + return ( + <> + + + + ) + } + const view = await env.render( + + + + ) + const fields = within(view.container) + try { + await waitFor(() => + assert.equal( + ( + fields.getByRole('spinbutton', { + name: 'Custom Amount', + }) as HTMLInputElement + ).value, + '10' + ) + ) + await act(async () => { + fireEvent.change( + fields.getByRole('spinbutton', { name: 'Custom Amount' }), + { target: { value: '0' } } + ) + }) + await act(async () => { + fireEvent.change( + fields.getByRole('spinbutton', { name: 'Custom Amount' }), + { target: { value: '05' } } + ) + }) + assert.equal( + ( + fields.getByRole('spinbutton', { + name: 'Custom Amount', + }) as HTMLInputElement + ).value, + '05' + ) + await act(async () => { + fireEvent.change( + fields.getByRole('spinbutton', { name: 'Custom Amount' }), + { target: { value: '1.5' } } + ) + }) + assert.equal( + ( + fields.getByRole('spinbutton', { + name: 'Custom Amount', + }) as HTMLInputElement + ).value, + '1.5' + ) + await act(async () => { + fireEvent.change( + fields.getByRole('spinbutton', { name: 'Custom Amount' }), + { target: { value: '' } } + ) + }) + assert.equal( + ( + fields.getByRole('spinbutton', { + name: 'Custom Amount', + }) as HTMLInputElement + ).value, + '' + ) + await view.click(fields.getByText('Render wallet again')) + assert.equal( + ( + fields.getByRole('spinbutton', { + name: 'Custom Amount', + }) as HTMLInputElement + ).value, + '' + ) + assert.deepEqual(quotes, [10, 0, 5, 1, 0]) + assert.deepEqual(mutations, []) + } finally { + await view.unmount() + client.clear() + api.defaults.adapter = adapter + } +}) + +test('subscription reads ignore stale responses after the auth session changes', async () => { + const adapter = api.defaults.adapter + const previousUser = useAuthStore.getState().auth.user + const pending: Array<() => void> = [] + const subscription = (id: number) => ({ + id, + user_id: id, + plan_id: 1, + status: 'active', + start_time: 1710000000, + end_time: 1910000000, + amount_total: 100, + amount_used: 0, + }) + try { + useAuthStore + .getState() + .auth.setUser({ id: 1, username: 'first-user', role: 1 }) + api.defaults.adapter = async (config) => { + assert.equal(config.method, 'get') + if (config.url === '/api/subscription/plans') { + return { + config, + status: 200, + statusText: 'OK', + headers: {}, + data: { success: true, data: [] }, + } + } + if (config.url === '/api/subscription/self') { + const index = pending.length + return new Promise((resolve) => { + pending.push(() => + resolve({ + config, + status: 200, + statusText: 'OK', + headers: {}, + data: { + success: true, + data: { + billing_preference: + index === 0 ? 'wallet_first' : 'wallet_only', + subscriptions: [ + { subscription: subscription(index === 0 ? 1 : 2) }, + ], + all_subscriptions: [ + { subscription: subscription(index === 0 ? 1 : 2) }, + ], + }, + }, + }) + ) + }) + } + throw new Error(`Unexpected ${config.url}`) + } + const view = await env.render( + + ) + try { + await waitFor(() => assert.equal(pending.length, 1)) + await act(async () => { + useAuthStore + .getState() + .auth.setUser({ id: 2, username: 'second-user', role: 1 }) + }) + await waitFor(() => assert.equal(pending.length, 2)) + await act(async () => pending[1]()) + await waitFor(() => + assert.match(view.container.textContent || '', /Subscription #2/) + ) + await act(async () => pending[0]()) + await new Promise((resolve) => setTimeout(resolve, 0)) + assert.match(view.container.textContent || '', /Subscription #2/) + assert.doesNotMatch(view.container.textContent || '', /Subscription #1/) + } finally { + pending.forEach((release) => release()) + await view.unmount() + } + } finally { + api.defaults.adapter = adapter + useAuthStore.getState().auth.setUser(previousUser) + } +}) + +test('epay selection survives a host rerender and an in-flight payment stays locked across reopen', async () => { + const adapter = api.defaults.adapter + const plan: PlanRecord = { + plan: subscriptionPlanSchema.parse({ + id: 1, + title: 'Synthetic plan', + price_amount: 1, + currency: 'USD', + duration_unit: 'month', + duration_value: 1, + quota_reset_period: 'never', + enabled: true, + sort_order: 0, + allow_balance_pay: false, + max_purchase_per_user: 0, + total_amount: 0, + }), + } + const payments: unknown[] = [] + let release!: () => void + api.defaults.adapter = async (config) => { + assert.equal(config.method, 'post') + payments.push(JSON.parse(config.data)) + await new Promise((resolve) => { + release = resolve + }) + return { + config, + status: 200, + statusText: 'OK', + headers: {}, + data: { success: false, message: 'Synthetic payment declined' }, + } + } + function Harness() { + const [open, setOpen] = useState(true) + const [, setRevision] = useState(0) + return ( + <> + + + + + ) + } + const view = await env.render() + const body = within(env.dom.window.document.body) + try { + await view.click(body.getByRole('combobox')) + await view.click(body.getByRole('option', { name: 'Synthetic WeChat' })) + await view.click(within(view.container).getByText('Render purchase again')) + assert.ok( + body.getByRole('combobox').textContent?.includes('Synthetic WeChat') + ) + await view.click(body.getByRole('button', { name: 'Pay' })) + assert.equal(payments.length, 1) + assert.deepEqual(payments[0], { plan_id: 1, payment_method: 'wxpay' }) + await view.click(within(view.container).getByText('Toggle purchase')) + await view.click(within(view.container).getByText('Toggle purchase')) + const pay = body.getByRole('button', { + name: 'Pay', + }) as HTMLButtonElement + assert.equal(pay.disabled, true) + await view.click(pay) + assert.equal(payments.length, 1) + await act(async () => release()) + assert.equal( + ( + body.getByRole('button', { + name: 'Pay', + }) as HTMLButtonElement + ).disabled, + false + ) + } finally { + release?.() + await view.unmount() + api.defaults.adapter = adapter + } +}) + +test('subscription expiry display advances without a refresh or any financial write', async () => { + const adapter = api.defaults.adapter, + originalNow = Date.now + const interval = Object.getOwnPropertyDescriptor(globalThis, 'setTimeout')!, + clear = Object.getOwnPropertyDescriptor(globalThis, 'clearTimeout')! + let now = 1710000000000 + let tick: (() => void) | undefined + const requests: string[] = [] + Date.now = () => now + const nativeInterval = globalThis.setTimeout, + nativeClear = globalThis.clearTimeout + const clockHandle = Symbol('subscription display clock') + Object.defineProperty(globalThis, 'setTimeout', { + configurable: true, + value: (callback: () => void, milliseconds: number) => { + if (milliseconds !== 1001) return nativeInterval(callback, milliseconds) + tick = callback + return clockHandle + }, + }) + Object.defineProperty(globalThis, 'clearTimeout', { + configurable: true, + value: (handle: unknown) => { + if (handle === clockHandle) tick = undefined + else nativeClear(handle as ReturnType) + }, + }) + api.defaults.adapter = async (config) => { + assert.equal(config.method, 'get') + requests.push(config.url || '') + const subscription = { + id: 7, + user_id: 1, + plan_id: 1, + status: 'active', + start_time: now / 1000 - 60, + end_time: now / 1000 + 1, + amount_total: 100, + amount_used: 25, + } + const data = + config.url === '/api/subscription/plans' + ? [] + : { + billing_preference: 'subscription_first', + subscriptions: [{ subscription }], + all_subscriptions: [{ subscription }], + } + return { + config, + status: 200, + statusText: 'OK', + headers: {}, + data: { success: true, data }, + } + } + const view = await env.render( + + ) + const body = within(view.container) + try { + await waitFor(() => assert.ok(body.queryByText('Active'))) + await act(async () => { + now += 2000 + tick?.() + }) + assert.equal(body.queryByText('Active'), null) + assert.ok(body.queryByText('Expired')) + assert.equal(requests.length, 2) + } finally { + await view.unmount() + assert.equal(tick, undefined) + Date.now = originalNow + Object.defineProperty(globalThis, 'setTimeout', interval) + Object.defineProperty(globalThis, 'clearTimeout', clear) + api.defaults.adapter = adapter + } +}) diff --git a/web/default/src/hooks/state-lifecycle.runtime.test.tsx b/web/default/src/hooks/state-lifecycle.runtime.test.tsx new file mode 100644 index 00000000..9dd346f4 --- /dev/null +++ b/web/default/src/hooks/state-lifecycle.runtime.test.tsx @@ -0,0 +1,736 @@ +/* +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, useState } from 'react' +import { AxiosError } from 'axios' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { createReactTestEnvironment } from '@/test/react' +import assert from 'node:assert/strict' +import { after, test } from 'node:test' +import { useAuthStore } from '@/stores/auth-store' +import { api } from '@/lib/api' +import { useThemeRadiusPx } from '@/lib/theme-radius' +import type { UserProfile } from '@/features/profile/types' +import type { ChannelAffinitySettings } from '@/features/system-settings/general/channel-affinity/types' +import { useMinimumLoadingTime } from './use-minimum-loading-time' +import { useIsMobile } from './use-mobile' +import { useReadScope } from './use-read-scope' +import { useResettableState } from './use-resettable-state' + +const env = createReactTestEnvironment() +await env.setup() +const { fireEvent, within } = await import('@testing-library/react/pure') +const { useProfile } = await import('@/features/profile/hooks/use-profile') +const { NotificationTab } = + await import('@/features/profile/components/tabs/notification-tab') +const { ChannelAffinitySection } = + await import('@/features/system-settings/general/channel-affinity') +const { SettingsPageProvider } = + await import('@/features/system-settings/components/settings-page-context') +const { SidebarModulesCard } = + await import('@/features/profile/components/sidebar-modules-card') +after(() => env.teardown()) + +test('sidebar settings recover malformed values and preserve explicit false without autosaving', async () => { + const adapter = api.defaults.adapter, + previousUser = useAuthStore.getState().auth.user + const defaults = { + chat: { enabled: true, playground: true, chat: true }, + console: { + enabled: true, + detail: true, + token: true, + log: true, + midjourney: true, + task: true, + }, + personal: { enabled: true, topup: true, personal: true }, + } + const valid = { chat: { enabled: false, chat: false } } + const cases = ['{broken', 'null', '[]', 'false', '42', JSON.stringify(valid)] + try { + for (const [index, raw] of cases.entries()) { + useAuthStore + .getState() + .auth.setUser({ id: 30 + index, username: 'sidebar-user', role: 1 }) + const saved: unknown[] = [] + api.defaults.adapter = async (config) => { + assert.equal(config.url, '/api/user/self') + if (config.method === 'put') + saved.push(JSON.parse(JSON.parse(config.data).sidebar_modules)) + else assert.equal(config.method, 'get') + return { + config, + status: 200, + statusText: 'OK', + headers: {}, + data: { success: true, data: { sidebar_modules: raw } }, + } + } + const view = await env.render() + try { + assert.equal(saved.length, 0) + await view.click( + within(view.container).getByRole('button', { name: 'Save Changes' }) + ) + assert.deepEqual(saved, [index === cases.length - 1 ? valid : defaults]) + } finally { + await view.unmount() + } + } + } finally { + api.defaults.adapter = adapter + useAuthStore.getState().auth.setUser(previousUser) + } +}) + +test('channel affinity drafts survive equal saved values and reset together after a saved change', async () => { + const adapter = api.defaults.adapter + const requests: string[] = [] + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }) + const actions = env.dom.window.document.createElement('div') + env.dom.window.document.body.append(actions) + const initial: ChannelAffinitySettings = { + 'channel_affinity_setting.enabled': true, + 'channel_affinity_setting.switch_on_success': true, + 'channel_affinity_setting.keep_on_channel_disabled': false, + 'channel_affinity_setting.max_entries': 100, + 'channel_affinity_setting.default_ttl_seconds': 60, + 'channel_affinity_setting.rules': '[]', + } + api.defaults.adapter = async (config) => { + requests.push(config.method || '') + assert.equal(config.method, 'get') + return { + config, + status: 200, + statusText: 'OK', + headers: {}, + data: { success: true, data: null }, + } + } + function Harness() { + const [saved, setSaved] = useState(initial) + const [, setRevision] = useState(0) + return ( + <> + + + + + ) + } + const view = await env.render( + + + + + + ) + const fields = within(view.container), + controls = within(actions) + const switches = () => fields.getAllByRole('switch') + const amounts = () => fields.getAllByRole('spinbutton') as HTMLInputElement[] + try { + for (const field of switches()) await view.click(field) + await act(async () => { + fireEvent.change(amounts()[0], { target: { value: '0' } }) + fireEvent.change(amounts()[1], { target: { value: '0' } }) + }) + await view.click(controls.getByRole('button', { name: 'JSON' })) + const draft = '[{"name":"unsaved-rule","ttl_seconds":0}]' + await act(async () => { + fireEvent.change(fields.getByRole('textbox'), { + target: { value: draft }, + }) + }) + await view.click(fields.getByText('Refresh affinity host')) + assert.deepEqual( + switches().map((field) => field.getAttribute('aria-checked')), + ['false', 'false', 'true'] + ) + assert.deepEqual( + amounts().map((field) => field.value), + ['0', '0'] + ) + assert.equal( + (fields.getByRole('textbox') as HTMLTextAreaElement).value, + draft + ) + await view.click(controls.getByRole('button', { name: 'Visual' })) + await view.click(fields.getByText('Refresh affinity host')) + assert.ok(fields.getByText('unsaved-rule')) + await view.click(fields.getByText('Change saved affinity')) + assert.deepEqual( + switches().map((field) => field.getAttribute('aria-checked')), + ['true', 'true', 'false'] + ) + assert.deepEqual( + amounts().map((field) => field.value), + ['222', '60'] + ) + assert.equal(fields.queryByText('unsaved-rule'), null) + assert.ok(fields.getByText('saved-rule')) + await view.click(controls.getByRole('button', { name: 'JSON' })) + assert.deepEqual( + JSON.parse((fields.getByRole('textbox') as HTMLTextAreaElement).value), + [{ name: 'saved-rule' }] + ) + assert.deepEqual(requests, ['get']) + } finally { + await view.unmount() + actions.remove() + client.clear() + api.defaults.adapter = adapter + } +}) + +test('notification draft survives an unchanged profile refresh and resets for saved settings or account', async () => { + const adapter = api.defaults.adapter + const writes: Array<{ + quota_warning_threshold?: number + record_ip_log?: boolean + }> = [] + let updated = 0 + const initial: UserProfile = { + id: 1, + username: 'notification-user', + display_name: 'Notification user', + role: 1, + group: 'default', + quota: 0, + used_quota: 0, + request_count: 0, + status: 1, + aff_count: 0, + aff_quota: 0, + aff_history_quota: 0, + created_time: 0, + setting: JSON.stringify({ + notify_type: 'email', + quota_warning_threshold: 100, + record_ip_log: false, + }), + } + api.defaults.adapter = async (config) => { + assert.equal(config.method, 'put') + assert.equal(config.url, '/api/user/setting') + writes.push(JSON.parse(config.data)) + return { + config, + status: 200, + statusText: 'OK', + headers: {}, + data: { success: true }, + } + } + function Harness() { + const [profile, setProfile] = useState(initial) + return ( + <> + + + + + { + updated += 1 + }} + /> + + ) + } + const view = await env.render() + const fields = within(view.container) + const threshold = () => + fields.getByRole('spinbutton', { + name: 'Quota Warning Threshold', + }) as HTMLInputElement + const editZero = () => + act(async () => { + fireEvent.change(threshold(), { target: { value: '0' } }) + }) + try { + assert.equal(threshold().value, '100') + await editZero() + assert.equal(threshold().value, '0') + await view.click(fields.getByText('Refresh same settings')) + assert.equal(threshold().value, '0') + await view.click(fields.getByText('Change saved settings')) + assert.equal(threshold().value, '10') + await editZero() + await view.click(fields.getByText('Switch account')) + assert.equal(threshold().value, '10') + await view.click(fields.getByText('Save explicit zero')) + assert.equal(threshold().value, '0') + await view.click(fields.getByRole('button', { name: 'Save Settings' })) + assert.equal(writes.length, 1) + assert.equal(writes[0].quota_warning_threshold, 0) + assert.equal(writes[0].record_ip_log, false) + assert.equal(updated, 1) + } finally { + await view.unmount() + api.defaults.adapter = adapter + } +}) + +test('failed settings writes do not log credential-bearing request bodies or retry', async () => { + const adapter = api.defaults.adapter + const originalLog = Object.getOwnPropertyDescriptor(console, 'error')! + const logs: unknown[][] = [] + const marker = 'synthetic-private-marker' + let writes = 0 + api.defaults.adapter = async (config) => { + if (config.method === 'get') + return { + config, + status: 200, + statusText: 'OK', + headers: {}, + data: { + success: true, + data: { id: 1, username: 'logging-profile', role: 1 }, + }, + } + writes += 1 + throw new AxiosError( + 'Synthetic update failure', + 'ERR_BAD_RESPONSE', + config, + undefined, + { config, status: 500, statusText: 'Error', headers: {}, data: {} } + ) + } + Object.defineProperty(console, 'error', { + ...originalLog, + value: (...args: unknown[]) => logs.push(args), + }) + function Probe() { + const { updateSettings } = useProfile() + const [result, setResult] = useState('pending') + return ( + <> + {result} + + + ) + } + const view = await env.render() + try { + await view.click(within(view.container).getByText('Save private setting')) + assert.equal( + within(view.container).getByRole('status').textContent, + 'false' + ) + assert.equal(writes, 1) + assert.ok(logs.length > 0) + assert.equal(JSON.stringify(logs).includes(marker), false) + } finally { + await view.unmount() + Object.defineProperty(console, 'error', originalLog) + api.defaults.adapter = adapter + } +}) + +test('a silent refresh can finish a visible profile load without leaving its spinner stuck', async () => { + const adapter = api.defaults.adapter + let release!: () => void + let requests = 0 + api.defaults.adapter = async (config) => { + requests += 1 + await new Promise((resolve) => { + release = resolve + }) + return { + config, + status: 200, + statusText: 'OK', + headers: {}, + data: { + success: true, + data: { id: 1, username: 'profile-ready', role: 1 }, + }, + } + } + function Probe() { + const { loading, profile, refreshProfile } = useProfile() + return ( + <> + {String(loading)} + {profile?.username || '-'} + + + ) + } + const view = await env.render() + const fields = within(view.container) + try { + assert.equal( + fields.getByRole('status', { name: 'Loading' }).textContent, + 'true' + ) + await view.click(fields.getByText('Silent refresh')) + await act(async () => release()) + assert.equal( + fields.getByRole('status', { name: 'Loading' }).textContent, + 'false' + ) + assert.equal( + fields.getByRole('status', { name: 'Profile' }).textContent, + 'profile-ready' + ) + assert.equal(requests, 1) + } finally { + release?.() + await view.unmount() + api.defaults.adapter = adapter + } +}) + +test('drafts keep local edits on rerender and reset explicit zero and false with their source', async () => { + const oldSetters: Array< + (value: { count: number; enabled: boolean }) => void + > = [] + function Harness() { + const [source, setSource] = useState(1) + const [, setRevision] = useState(0) + const [draft, setDraft] = useResettableState( + { count: 0, enabled: false }, + source + ) + return ( + <> + {JSON.stringify(draft)} + + + + + + ) + } + const view = await env.render() + const fields = within(view.container) + try { + await view.click(fields.getByText('Edit')) + await view.click(fields.getByText('Render again')) + assert.equal( + fields.getByRole('status').textContent, + '{"count":7,"enabled":true}' + ) + await view.click(fields.getByText('Change source')) + assert.equal( + fields.getByRole('status').textContent, + '{"count":0,"enabled":false}' + ) + await view.click(fields.getByText('Reuse first source')) + await act(async () => oldSetters[0]({ count: 99, enabled: true })) + assert.equal( + fields.getByRole('status').textContent, + '{"count":0,"enabled":false}' + ) + } finally { + await view.unmount() + } +}) + +test('read scopes reject older requests, changed entities, sessions and unmounted results', async () => { + const previousUser = useAuthStore.getState().auth.user + const reads: Array<() => boolean> = [] + const starts: Array<() => () => boolean> = [] + function Harness() { + const [key, setKey] = useState(1) + const begin = useReadScope(key) + return ( + <> + + + + + ) + } + const view = await env.render() + const fields = within(view.container) + try { + await view.click(fields.getByText('Read')) + await view.click(fields.getByText('Read')) + assert.equal(reads[0](), false) + assert.equal(reads[1](), true) + await view.click(fields.getByText('Next')) + assert.equal(reads[1](), false) + await view.click(fields.getByText('Read')) + assert.equal(reads[2](), true) + assert.equal(starts[0]()(), false) + assert.equal(reads[2](), true) + await view.click(fields.getByText('First again')) + await view.click(fields.getByText('Read')) + assert.equal(starts[0]()(), false) + assert.equal(reads[3](), true) + await act(async () => useAuthStore.getState().auth.reset()) + assert.equal(reads[3](), false) + await view.click(fields.getByText('Read')) + await view.unmount() + assert.equal(reads[4](), false) + } finally { + useAuthStore.getState().auth.setUser(previousUser) + } +}) + +test('mobile snapshot starts at the current width and unsubscribes on unmount', async () => { + const original = env.dom.window.matchMedia + const width = Object.getOwnPropertyDescriptor(env.dom.window, 'innerWidth') + const listeners = new Set<() => void>() + let currentWidth = 500 + Object.defineProperty(env.dom.window, 'innerWidth', { + configurable: true, + get: () => currentWidth, + }) + Object.defineProperty(env.dom.window, 'matchMedia', { + configurable: true, + value: () => ({ + addEventListener: (_event: string, listener: () => void) => + listeners.add(listener), + removeEventListener: (_event: string, listener: () => void) => + listeners.delete(listener), + }), + }) + function Probe() { + return {String(useIsMobile())} + } + const view = await env.render() + try { + assert.equal(view.container.textContent, 'true') + await act(async () => { + currentWidth = 900 + listeners.forEach((notify) => notify()) + }) + assert.equal(view.container.textContent, 'false') + await view.unmount() + assert.equal(listeners.size, 0) + } finally { + Object.defineProperty(env.dom.window, 'matchMedia', { + configurable: true, + value: original, + }) + if (width) Object.defineProperty(env.dom.window, 'innerWidth', width) + } +}) + +test('minimum loading lasts for the current cycle and old timers cannot end a newer cycle', async () => { + const descriptors = new Map( + ['setTimeout', 'clearTimeout'].map((key) => [ + key, + Object.getOwnPropertyDescriptor(globalThis, key)!, + ]) + ) + const originalNow = Date.now + const timers = new Map void }>() + let now = 0, + nextId = 0 + Date.now = () => now + Object.defineProperty(globalThis, 'setTimeout', { + configurable: true, + value: (run: () => void, delay = 0) => { + const id = ++nextId + timers.set(id, { at: now + delay, run }) + return id + }, + }) + Object.defineProperty(globalThis, 'clearTimeout', { + configurable: true, + value: (id: number) => timers.delete(id), + }) + const advance = async (milliseconds: number) => + act(async () => { + now += milliseconds + for (const [id, timer] of [...timers]) + if (timer.at <= now) { + timers.delete(id) + timer.run() + } + }) + function Harness() { + const [loading, setLoading] = useState(false) + const visible = useMinimumLoadingTime(loading, 100) + return ( + <> + {String(visible)} + + + + ) + } + const view = await env.render() + const fields = within(view.container) + const visible = () => fields.getByRole('status').textContent + try { + assert.equal(visible(), 'false') + await view.click(fields.getByText('Start')) + await advance(40) + await view.click(fields.getByText('Stop')) + assert.equal(visible(), 'true') + await advance(60) + assert.equal(visible(), 'false') + await view.click(fields.getByText('Start')) + await advance(100) + assert.equal(visible(), 'true') + await view.click(fields.getByText('Stop')) + assert.equal(visible(), 'false') + await view.click(fields.getByText('Start')) + await advance(20) + await view.click(fields.getByText('Stop')) + await view.click(fields.getByText('Start')) + await view.click(fields.getByText('Stop')) + await advance(80) + assert.equal(visible(), 'true') + await advance(20) + assert.equal(visible(), 'false') + } finally { + await view.unmount() + assert.equal(timers.size, 0) + Date.now = originalNow + for (const [key, descriptor] of descriptors) + Object.defineProperty(globalThis, key, descriptor) + } +}) + +test('theme radius follows DOM changes and a refresh key without leaving resize listeners', async () => { + const computed = Object.getOwnPropertyDescriptor( + globalThis, + 'getComputedStyle' + )! + let radius = 12, + measurements = 0 + Object.defineProperty(globalThis, 'getComputedStyle', { + configurable: true, + value: () => { + measurements += 1 + return { borderTopLeftRadius: `${radius}px` } + }, + }) + function Harness() { + const [revision, setRevision] = useState(0) + const value = useThemeRadiusPx('--radius-md', String(revision)) + return ( + <> + {String(value)} + + + ) + } + const view = await env.render() + try { + assert.equal(within(view.container).getByRole('status').textContent, '12') + await act(async () => { + radius = 24 + env.dom.window.document.documentElement.setAttribute( + 'data-theme', + 'test-dark' + ) + }) + assert.equal(within(view.container).getByRole('status').textContent, '24') + radius = 36 + await view.click(within(view.container).getByText('Refresh')) + assert.equal(within(view.container).getByRole('status').textContent, '36') + await view.unmount() + const before = measurements + env.dom.window.dispatchEvent(new env.dom.window.Event('resize')) + assert.equal(measurements, before) + } finally { + Object.defineProperty(globalThis, 'getComputedStyle', computed) + } +}) diff --git a/web/default/src/hooks/use-minimum-loading-time.ts b/web/default/src/hooks/use-minimum-loading-time.ts index 2d297835..7a808fcc 100644 --- a/web/default/src/hooks/use-minimum-loading-time.ts +++ b/web/default/src/hooks/use-minimum-loading-time.ts @@ -26,26 +26,34 @@ export function useMinimumLoadingTime( loading: boolean, minimumTime = 1000 ): boolean { - const [showSkeleton, setShowSkeleton] = useState(loading) - // eslint-disable-next-line react-hooks/purity - const loadingStartRef = useRef(Date.now()) - + const [period, setPeriod] = useState(() => ({ + loading, + cycle: loading ? 1 : 0, + elapsed: !loading, + })) + const started = useRef({ cycle: 0, time: 0 }) + if (period.loading !== loading) { + setPeriod({ + loading, + cycle: loading ? period.cycle + 1 : period.cycle, + elapsed: loading ? false : period.elapsed, + }) + } + const cycle = period.cycle useEffect(() => { - if (loading) { - loadingStartRef.current = Date.now() - setShowSkeleton(true) - } else { - const elapsed = Date.now() - loadingStartRef.current - const remaining = Math.max(0, minimumTime - elapsed) - - if (remaining === 0) { - setShowSkeleton(false) - } else { - const timer = setTimeout(() => setShowSkeleton(false), remaining) - return () => clearTimeout(timer) - } - } - }, [loading, minimumTime]) - - return showSkeleton + if (cycle === 0) return + if (started.current.cycle !== cycle) + started.current = { cycle, time: Date.now() } + const remaining = Math.max( + 0, + minimumTime - (Date.now() - started.current.time) + ) + const timer = setTimeout(() => { + setPeriod((previous) => + previous.cycle === cycle ? { ...previous, elapsed: true } : previous + ) + }, remaining) + return () => clearTimeout(timer) + }, [cycle, minimumTime]) + return loading || (minimumTime > 0 && cycle > 0 && !period.elapsed) } diff --git a/web/default/src/hooks/use-mobile.ts b/web/default/src/hooks/use-mobile.ts index 55562f80..f5a74728 100644 --- a/web/default/src/hooks/use-mobile.ts +++ b/web/default/src/hooks/use-mobile.ts @@ -19,19 +19,19 @@ For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API import * as React from 'react' const MOBILE_BREAKPOINT = 768 - +function subscribe(onChange: () => void) { + const query = window.matchMedia( + '(max-width: ' + (MOBILE_BREAKPOINT - 1) + 'px)' + ) + query.addEventListener('change', onChange) + return () => query.removeEventListener('change', onChange) +} +function getSnapshot() { + return window.innerWidth < MOBILE_BREAKPOINT +} +function getServerSnapshot() { + return false +} export function useIsMobile() { - const [isMobile, setIsMobile] = React.useState(undefined) - - React.useEffect(() => { - const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`) - const onChange = () => { - setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) - } - mql.addEventListener('change', onChange) - setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) - return () => mql.removeEventListener('change', onChange) - }, []) - - return !!isMobile + return React.useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot) } diff --git a/web/default/src/hooks/use-read-scope.ts b/web/default/src/hooks/use-read-scope.ts new file mode 100644 index 00000000..9ab112ae --- /dev/null +++ b/web/default/src/hooks/use-read-scope.ts @@ -0,0 +1,60 @@ +/* +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 { useCallback, useLayoutEffect, useRef, useState } from 'react' +import { useAuthStore } from '@/stores/auth-store' + +/** Ignore obsolete reads; this does not cancel a request or undo a server mutation. */ +export function useReadScope(scopeKey?: unknown) { + const sessionRevision = useAuthStore((state) => state.sessionRevision) + const revision = useRef(0) + const mounted = useRef(false) + const [owner, setOwner] = useState(() => ({ key: scopeKey, sessionRevision })) + let scope = owner + if ( + !Object.is(owner.key, scopeKey) || + owner.sessionRevision !== sessionRevision + ) { + scope = { key: scopeKey, sessionRevision } + setOwner(scope) + } + const activeScope = useRef(scope) + useLayoutEffect(() => { + activeScope.current = scope + mounted.current = true + revision.current += 1 + return () => { + mounted.current = false + revision.current += 1 + } + }, [scope]) + return useCallback(() => { + if ( + !mounted.current || + activeScope.current !== scope || + useAuthStore.getState().sessionRevision !== scope.sessionRevision + ) + return () => false + const current = ++revision.current + return () => + mounted.current && + revision.current === current && + activeScope.current === scope && + useAuthStore.getState().sessionRevision === scope.sessionRevision + }, [scope]) +} diff --git a/web/default/src/hooks/use-resettable-state.ts b/web/default/src/hooks/use-resettable-state.ts new file mode 100644 index 00000000..93dc2f29 --- /dev/null +++ b/web/default/src/hooks/use-resettable-state.ts @@ -0,0 +1,66 @@ +/* +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 { + useCallback, + useState, + type Dispatch, + type SetStateAction, +} from 'react' + +/** Keep an editable draft until its source identity changes, before children commit. */ +export function useResettableState( + initialValue: T | (() => T), + resetKey: unknown +): [T, Dispatch>] { + const [draft, setDraft] = useState(() => ({ + key: resetKey, + scope: {}, + value: + typeof initialValue === 'function' + ? (initialValue as () => T)() + : initialValue, + })) + let current = draft + if (!Object.is(draft.key, resetKey)) { + const nextValue = + typeof initialValue === 'function' + ? (initialValue as () => T)() + : initialValue + current = { key: resetKey, scope: {}, value: nextValue } + setDraft(current) + } + const scope = current.scope + const setValue = useCallback>>( + (action) => { + setDraft((previous) => + previous.scope !== scope + ? previous + : { + ...previous, + value: + typeof action === 'function' + ? (action as (value: T) => T)(previous.value) + : action, + } + ) + }, + [scope] + ) + return [current.value, setValue] +} diff --git a/web/default/src/lib/api-session.runtime.test.ts b/web/default/src/lib/api-session.runtime.test.ts new file mode 100644 index 00000000..d8e9b887 --- /dev/null +++ b/web/default/src/lib/api-session.runtime.test.ts @@ -0,0 +1,324 @@ +/* +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 { AxiosError, type InternalAxiosRequestConfig } from 'axios' +import { QueryCache, QueryClient } from '@tanstack/react-query' +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { useAuthStore } from '@/stores/auth-store' +import { appSessionVerifier } from '@/features/auth/lib/app-session-verifier' +import { api } from './api' +import { handleQueryAuthenticationError } from './auth-errors' + +test('anonymous no-op assignment preserves a pending GET while explicit reset invalidates it', async () => { + const adapter = api.defaults.adapter, + user = useAuthStore.getState().auth.user + const releases: Array<() => void> = [], + calls: Array> = [] + let transports = 0 + api.defaults.adapter = async (config) => { + transports += 1 + await new Promise((resolve) => releases.push(resolve)) + return { + config, + status: 200, + statusText: 'OK', + headers: {}, + data: { success: true }, + } + } + try { + useAuthStore.getState().auth.reset() + const revision = useAuthStore.getState().sessionRevision + const first = api.get('/test/anonymous-no-op') + calls.push(first) + useAuthStore.getState().auth.setUser(null) + assert.equal(useAuthStore.getState().sessionRevision, revision) + assert.equal(api.get('/test/anonymous-no-op'), first) + useAuthStore.getState().auth.reset() + assert.equal(useAuthStore.getState().sessionRevision, revision + 1) + const next = api.get('/test/anonymous-no-op') + calls.push(next) + assert.notEqual(next, first) + assert.equal(transports, 2) + } finally { + releases.forEach((resolve) => resolve()) + await Promise.allSettled(calls) + api.defaults.adapter = adapter + useAuthStore.getState().auth.setUser(user) + } +}) + +test('GET dedup stays within a login session, including same-user logout and login', async () => { + const adapter = api.defaults.adapter + const user = useAuthStore.getState().auth.user + const pending: Array<() => void> = [] + let transports = 0 + api.defaults.adapter = async (config) => { + const owner = useAuthStore.getState().auth.user?.id + transports += 1 + await new Promise((resolve) => pending.push(resolve)) + return { + config, + status: 200, + statusText: 'OK', + headers: {}, + data: { owner }, + } + } + const calls: Array> = [] + try { + useAuthStore.getState().auth.setUser({ id: 1, username: 'first', role: 1 }) + const first = api.get('/test/session-data') + calls.push(first) + await new Promise((resolve) => setImmediate(resolve)) + assert.equal(transports, 1) + useAuthStore.getState().auth.setUser({ id: 2, username: 'second', role: 1 }) + const second = api.get('/test/session-data') + calls.push(second) + assert.notEqual(second, first) + await new Promise((resolve) => setImmediate(resolve)) + assert.equal(transports, 2) + pending[0]() + assert.equal((await first).data.owner, 1) + assert.equal(api.get('/test/session-data'), second) + useAuthStore + .getState() + .auth.setUser({ id: 2, username: 'second', role: 1, quota: 0 }) + assert.equal(api.get('/test/session-data'), second) + useAuthStore.getState().auth.setUser({ + id: 2, + username: 'second', + role: 1, + permissions: { sidebar_settings: false }, + }) + const permissionsChanged = api.get('/test/session-data') + calls.push(permissionsChanged) + assert.notEqual(permissionsChanged, second) + await new Promise((resolve) => setImmediate(resolve)) + assert.equal(transports, 3) + useAuthStore.getState().auth.reset() + useAuthStore.getState().auth.setUser({ id: 2, username: 'second', role: 1 }) + const relogin = api.get('/test/session-data') + calls.push(relogin) + assert.notEqual(relogin, permissionsChanged) + await new Promise((resolve) => setImmediate(resolve)) + assert.equal(transports, 4) + } finally { + pending.forEach((resolve) => resolve()) + await Promise.allSettled(calls) + api.defaults.adapter = adapter + useAuthStore.getState().auth.setUser(user) + } +}) + +test('a late 401 rejects the original call without logging out a newer session', async () => { + const adapter = api.defaults.adapter + const user = useAuthStore.getState().auth.user + let rejectRequest: (() => void) | undefined + let started!: () => void + const ready = new Promise((resolve) => { + started = resolve + }) + api.defaults.adapter = (config: InternalAxiosRequestConfig) => + new Promise((_, reject) => { + rejectRequest = () => + reject( + new AxiosError('Expired', 'ERR_BAD_REQUEST', config, undefined, { + config, + status: 401, + statusText: 'Unauthorized', + headers: {}, + data: {}, + }) + ) + started() + }) + try { + useAuthStore.getState().auth.setUser({ id: 1, username: 'old', role: 1 }) + const old = api.post('/test/no-retry', {}, { skipErrorHandler: true }) + const rejected = assert.rejects(old, /Expired/) + await ready + useAuthStore.getState().auth.setUser({ id: 2, username: 'new', role: 1 }) + rejectRequest!() + await rejected + assert.equal(useAuthStore.getState().auth.user?.id, 2) + const current = api.get('/test/current', { skipErrorHandler: true }) + const currentRejected = assert.rejects(current, /Expired/) + await new Promise((resolve) => setImmediate(resolve)) + rejectRequest!() + await currentRejected + assert.equal(useAuthStore.getState().auth.user, null) + } finally { + api.defaults.adapter = adapter + useAuthStore.getState().auth.setUser(user) + } +}) + +test('query-cache error handling must not let an old 401 expire a newer session', async () => { + const adapter = api.defaults.adapter, + user = useAuthStore.getState().auth.user + let rejectRequest!: () => void, + started!: () => void, + expired = 0 + const ready = new Promise((resolve) => { + started = resolve + }) + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + queryCache: new QueryCache({ + onError: (error) => + handleQueryAuthenticationError(error, () => { + expired += 1 + useAuthStore.getState().auth.reset() + }), + }), + }) + api.defaults.adapter = (config) => + new Promise((_, reject) => { + rejectRequest = () => + reject( + new AxiosError( + 'Expired query', + 'ERR_BAD_REQUEST', + config, + undefined, + { + config, + status: 401, + statusText: 'Unauthorized', + headers: {}, + data: {}, + } + ) + ) + started() + }) + try { + useAuthStore.getState().auth.setUser({ id: 1, username: 'old', role: 1 }) + const failed = assert.rejects( + client.fetchQuery({ + queryKey: ['old-query'], + queryFn: () => api.get('/test/old-query', { skipErrorHandler: true }), + }), + /Expired query/ + ) + await ready + useAuthStore.getState().auth.setUser({ id: 2, username: 'new', role: 1 }) + rejectRequest() + await failed + assert.equal(expired, 0) + assert.equal(useAuthStore.getState().auth.user?.id, 2) + const currentFailed = assert.rejects( + client.fetchQuery({ + queryKey: ['current-query'], + queryFn: () => + api.get('/test/current-query', { skipErrorHandler: true }), + }), + /Expired query/ + ) + await new Promise((resolve) => setImmediate(resolve)) + rejectRequest() + await currentFailed + assert.equal(expired, 1) + assert.equal(useAuthStore.getState().auth.user, null) + } finally { + client.clear() + api.defaults.adapter = adapter + useAuthStore.getState().auth.setUser(user) + } +}) + +test('session verification cache is invalidated by same-user logout and login', async () => { + const adapter = api.defaults.adapter, + user = useAuthStore.getState().auth.user + let requests = 0 + api.defaults.adapter = async (config) => { + requests += 1 + return { + config, + status: 200, + statusText: 'OK', + headers: {}, + data: { + success: true, + data: { id: 1, username: 'verified-user', role: 1 }, + }, + } + } + try { + useAuthStore + .getState() + .auth.setUser({ id: 1, username: 'verified-user', role: 1 }) + assert.equal(await appSessionVerifier.verify(), true) + assert.equal(requests, 1) + useAuthStore.getState().auth.reset() + useAuthStore + .getState() + .auth.setUser({ id: 1, username: 'verified-user', role: 1 }) + assert.equal(await appSessionVerifier.verify(), true) + assert.equal(requests, 2) + } finally { + api.defaults.adapter = adapter + useAuthStore.getState().auth.setUser(user) + } +}) + +test('an anonymous verification cannot publish an older session user after reset', async () => { + const adapter = api.defaults.adapter, + user = useAuthStore.getState().auth.user + let release!: () => void, + started!: () => void, + requests = 0 + const ready = new Promise((resolve) => { + started = resolve + }) + api.defaults.adapter = async (config) => { + const id = ++requests === 1 ? 11 : 22 + if (id === 11) { + started() + await new Promise((resolve) => { + release = resolve + }) + } + return { + config, + status: 200, + statusText: 'OK', + headers: {}, + data: { + success: true, + data: { id, username: `verified-${id}`, role: 1 }, + }, + } + } + try { + useAuthStore.getState().auth.reset() + const verifying = appSessionVerifier.verify() + await ready + useAuthStore.getState().auth.reset() + release() + assert.equal(await verifying, true) + assert.equal(useAuthStore.getState().auth.user?.id, 22) + assert.equal(requests, 2) + } finally { + release?.() + api.defaults.adapter = adapter + useAuthStore.getState().auth.setUser(user) + } +}) diff --git a/web/default/src/lib/api.ts b/web/default/src/lib/api.ts index 28c9822a..f702a2a7 100644 --- a/web/default/src/lib/api.ts +++ b/web/default/src/lib/api.ts @@ -27,6 +27,8 @@ declare module 'axios' { skipBusinessError?: boolean skipErrorHandler?: boolean disableDuplicate?: boolean + authSessionRevision?: number + staleAuthResponse?: boolean } } @@ -55,6 +57,9 @@ export const api = axios.create({ // Deduplicate concurrent GET requests to the same URL // Prevents multiple identical requests from being sent simultaneously const inFlightGet = new Map>() +useAuthStore.subscribe((state, previous) => { + if (state.sessionRevision !== previous.sessionRevision) inFlightGet.clear() +}) const originalGet = api.get.bind(api) api.get = ((url: string, config: ApiRequestConfig = {}) => { @@ -62,7 +67,7 @@ api.get = ((url: string, config: ApiRequestConfig = {}) => { if (disableDuplicate) return originalGet(url, config) const params = config.params ? JSON.stringify(config.params) : '{}' - const key = `${url}?${params}` + const key = `${useAuthStore.getState().sessionRevision}:${url}?${params}` // Return existing in-flight request if available if (inFlightGet.has(key)) return inFlightGet.get(key)! @@ -102,6 +107,13 @@ api.interceptors.response.use( const status = error?.response?.status if (status === 401) { + const belongsToCurrentSession = + error?.config?.authSessionRevision === + useAuthStore.getState().sessionRevision + if (!belongsToCurrentSession) { + if (error.config) error.config.staleAuthResponse = true + return Promise.reject(error) + } try { useAuthStore.getState().auth.reset() } catch { @@ -160,14 +172,20 @@ export function getCommonHeaders(): Record { // ============================================================================ // Attach user ID header for all requests -api.interceptors.request.use((config) => { - const uid = getUserId() - if (uid) { - // Custom header for user identification - ;(config.headers as Record)['Max-Api-User'] = uid - } - return config -}) +api.interceptors.request.use( + (config) => { + config.authSessionRevision = useAuthStore.getState().sessionRevision + config.staleAuthResponse = false + const uid = getUserId() + if (uid) { + // Custom header for user identification + ;(config.headers as Record)['Max-Api-User'] = uid + } + return config + }, + undefined, + { synchronous: true } +) // ============================================================================ // Common API Functions diff --git a/web/default/src/lib/auth-errors.ts b/web/default/src/lib/auth-errors.ts new file mode 100644 index 00000000..441e9ca0 --- /dev/null +++ b/web/default/src/lib/auth-errors.ts @@ -0,0 +1,35 @@ +/* +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 { AxiosError } from 'axios' + +export function isStaleAuthResponse(error: unknown): boolean { + return error instanceof AxiosError && error.config?.staleAuthResponse === true +} + +export function handleQueryAuthenticationError( + error: unknown, + onExpired: () => void +): void { + if ( + error instanceof AxiosError && + error.response?.status === 401 && + !isStaleAuthResponse(error) + ) + onExpired() +} diff --git a/web/default/src/lib/theme-radius.ts b/web/default/src/lib/theme-radius.ts index 23cbee2a..ba3164e9 100644 --- a/web/default/src/lib/theme-radius.ts +++ b/web/default/src/lib/theme-radius.ts @@ -16,7 +16,7 @@ along with this program. If not, see . For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues */ -import { useEffect, useState } from 'react' +import { useMemo, useSyncExternalStore } from 'react' export function resolveThemeRadiusPx( cssVariable = '--radius-md' @@ -37,15 +37,51 @@ export function resolveThemeRadiusPx( return Number.isFinite(parsedRadius) ? parsedRadius : undefined } +function createRadiusStore(options: { + cssVariable: string + refreshKey?: string +}) { + let radius: number | undefined + const listeners = new Set<() => void>() + const measure = () => { + const next = resolveThemeRadiusPx(options.cssVariable) + if (Object.is(next, radius)) return + radius = next + for (const notify of listeners) notify() + } + return { + getSnapshot: () => radius, + getServerSnapshot: () => undefined, + subscribe: (notify: () => void) => { + listeners.add(notify) + const observer = new MutationObserver(measure) + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ['class', 'style', 'data-theme'], + }) + window.addEventListener('resize', measure) + measure() + return () => { + listeners.delete(notify) + observer.disconnect() + window.removeEventListener('resize', measure) + } + }, + } +} + export function useThemeRadiusPx( cssVariable = '--radius-md', refreshKey?: string ): number | undefined { - const [radius, setRadius] = useState() - - useEffect(() => { - setRadius(resolveThemeRadiusPx(cssVariable)) - }, [cssVariable, refreshKey]) - - return radius + // A changed theme key creates a fresh measurement after the DOM commits. + const store = useMemo( + () => createRadiusStore({ cssVariable, refreshKey }), + [cssVariable, refreshKey] + ) + return useSyncExternalStore( + store.subscribe, + store.getSnapshot, + store.getServerSnapshot + ) } diff --git a/web/default/src/main.tsx b/web/default/src/main.tsx index ed984042..8fc4450a 100644 --- a/web/default/src/main.tsx +++ b/web/default/src/main.tsx @@ -29,12 +29,19 @@ import i18next from 'i18next' import { toast } from 'sonner' import { useAuthStore } from '@/stores/auth-store' import { getStatus } from '@/lib/api' +import { + handleQueryAuthenticationError, + isStaleAuthResponse, +} from '@/lib/auth-errors' import { installBuildMetadata } from '@/lib/build-metadata' import { normalizeDefaultLogo } from '@/lib/constants' import '@/lib/dayjs' import { applyFaviconToDom } from '@/lib/dom-utils' import { initializeFrontendCache } from '@/lib/frontend-cache' -import { handleServerError } from '@/lib/handle-server-error' +import { + getSafeErrorDebugInfo, + handleServerError, +} from '@/lib/handle-server-error' import { DirectionProvider } from './context/direction-provider' import { FontProvider } from './context/font-provider' import { ThemeProvider } from './context/theme-provider' @@ -53,8 +60,10 @@ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: (failureCount, error) => { - // eslint-disable-next-line no-console - if (import.meta.env.DEV) console.log({ failureCount, error }) + if (import.meta.env.DEV) { + // eslint-disable-next-line no-console + console.log({ failureCount, error: getSafeErrorDebugInfo(error) }) + } if (failureCount >= 0 && import.meta.env.DEV) return false if (failureCount > 3 && import.meta.env.PROD) return false @@ -69,6 +78,7 @@ const queryClient = new QueryClient({ }, mutations: { onError: (error) => { + if (isStaleAuthResponse(error)) return handleServerError(error) if (error instanceof AxiosError) { @@ -81,14 +91,12 @@ const queryClient = new QueryClient({ }, queryCache: new QueryCache({ onError: (error) => { - if (error instanceof AxiosError) { - if (error.response?.status === 401) { - toast.error(i18next.t('Session expired!')) - useAuthStore.getState().auth.reset() - const redirect = `${router.history.location.href}` - router.navigate({ to: '/sign-in', search: { redirect } }) - } - } + handleQueryAuthenticationError(error, () => { + toast.error(i18next.t('Session expired!')) + useAuthStore.getState().auth.reset() + const redirect = `${router.history.location.href}` + router.navigate({ to: '/sign-in', search: { redirect } }) + }) }, }), }) diff --git a/web/default/src/stores/auth-store.ts b/web/default/src/stores/auth-store.ts index 24522187..19a40487 100644 --- a/web/default/src/stores/auth-store.ts +++ b/web/default/src/stores/auth-store.ts @@ -52,6 +52,7 @@ export interface AuthUser { } interface AuthState { + sessionRevision: number auth: { user: AuthUser | null setUser: (user: AuthUser | null) => void @@ -60,13 +61,31 @@ interface AuthState { } export const useAuthStore = create()((set) => ({ + sessionRevision: 0, auth: { user: null, setUser: (user) => - set((state) => ({ ...state, auth: { ...state.auth, user } })), + set((state) => { + const previous = state.auth.user + const identityChanged = + user === null || previous === null + ? user !== previous + : user.id !== previous.id || + user.role !== previous.role || + user.status !== previous.status || + user.group !== previous.group || + JSON.stringify(user.permissions ?? null) !== + JSON.stringify(previous.permissions ?? null) + return { + ...state, + sessionRevision: state.sessionRevision + (identityChanged ? 1 : 0), + auth: { ...state.auth, user }, + } + }), reset: () => set((state) => ({ ...state, + sessionRevision: state.sessionRevision + 1, auth: { ...state.auth, user: null }, })), }, diff --git a/web/default/src/test/react.test.ts b/web/default/src/test/react.test.ts index 00cb4560..bf3497be 100644 --- a/web/default/src/test/react.test.ts +++ b/web/default/src/test/react.test.ts @@ -16,10 +16,40 @@ along with this program. If not, see . For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues */ +import { act, createElement, useState, type ChangeEvent } from 'react' import assert from 'node:assert/strict' import { test } from 'node:test' import { createReactTestEnvironment } from './react' +test('controlled input changes reach React after installing browser globals', async () => { + const env = createReactTestEnvironment() + await env.setup() + const { fireEvent } = await import('@testing-library/react/pure') + const observed: string[] = [] + function Probe() { + const [value, setValue] = useState('initial') + return createElement('input', { + value, + onChange: (event: ChangeEvent) => { + observed.push(event.target.value) + setValue(event.target.value) + }, + }) + } + const view = await env.render(createElement(Probe)) + try { + await act(async () => { + fireEvent.change(view.container.querySelector('input')!, { + target: { value: 'updated' }, + }) + }) + assert.deepEqual(observed, ['updated']) + } finally { + await view.unmount() + env.teardown() + } +}) + test('teardown cancels pending animation frames before removing browser globals', async (): Promise => { const testEnv = createReactTestEnvironment() await testEnv.setup() diff --git a/web/default/src/test/react.tsx b/web/default/src/test/react.tsx index ed63e8fc..e0a12f69 100644 --- a/web/default/src/test/react.tsx +++ b/web/default/src/test/react.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 { act, type ReactElement } from 'react' -import { createRoot } from 'react-dom/client' import { createInstance, type Resource } from 'i18next' import { JSDOM } from 'jsdom' import { I18nextProvider } from 'react-i18next' @@ -187,6 +186,7 @@ export function createReactTestEnvironment(options?: { dom.window.close() }, async render(element: ReactElement) { + const { createRoot } = await import('react-dom/client') const container = dom.window.document.createElement('div') dom.window.document.body.append(container) const root = createRoot(container) 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,