From cc9b9279bd7d004999a63a6fb065e9ab6eec824e Mon Sep 17 00:00:00 2001 From: weishu Date: Fri, 6 Feb 2026 17:03:46 +0800 Subject: [PATCH] fix(web): require hub URL without clearing input --- web/src/App.tsx | 6 ++++ web/src/components/LoginPrompt.test.tsx | 48 +++++++++++++++++++++++++ web/src/components/LoginPrompt.tsx | 17 +++++++-- web/src/lib/locales/en.ts | 1 + web/src/lib/locales/zh-CN.ts | 1 + web/src/lib/runtime-config.ts | 12 +++++++ 6 files changed, 83 insertions(+), 2 deletions(-) create mode 100644 web/src/components/LoginPrompt.test.tsx create mode 100644 web/src/lib/runtime-config.ts diff --git a/web/src/App.tsx b/web/src/App.tsx index 24521358..8eb0a543 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -16,6 +16,7 @@ import { fetchLatestMessages } from '@/lib/message-window-store' import { useAppGoBack } from '@/hooks/useAppGoBack' import { useTranslation } from '@/lib/use-translation' import { VoiceProvider } from '@/lib/voice-context' +import { requireHubUrlForLogin } from '@/lib/runtime-config' import { LoginPrompt } from '@/components/LoginPrompt' import { InstallPrompt } from '@/components/InstallPrompt' import { OfflineBanner } from '@/components/OfflineBanner' @@ -29,6 +30,8 @@ import type { SyncEvent } from '@/types/api' type ToastEvent = Extract +const REQUIRE_SERVER_URL = requireHubUrlForLogin() + export function App() { return ( @@ -271,6 +274,7 @@ function AppInner() { serverUrl={serverUrl} setServerUrl={setServerUrl} clearServerUrl={clearServerUrl} + requireServerUrl={REQUIRE_SERVER_URL} /> ) } @@ -284,6 +288,7 @@ function AppInner() { serverUrl={serverUrl} setServerUrl={setServerUrl} clearServerUrl={clearServerUrl} + requireServerUrl={REQUIRE_SERVER_URL} error={authError ?? undefined} /> ) @@ -309,6 +314,7 @@ function AppInner() { serverUrl={serverUrl} setServerUrl={setServerUrl} clearServerUrl={clearServerUrl} + requireServerUrl={REQUIRE_SERVER_URL} error={authError ?? t('login.error.authFailed')} /> ) diff --git a/web/src/components/LoginPrompt.test.tsx b/web/src/components/LoginPrompt.test.tsx new file mode 100644 index 00000000..83b261df --- /dev/null +++ b/web/src/components/LoginPrompt.test.tsx @@ -0,0 +1,48 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import { I18nProvider } from '@/lib/i18n-context' +import { LoginPrompt } from './LoginPrompt' + +function renderWithProviders(ui: React.ReactElement) { + return render( + + {ui} + + ) +} + +describe('LoginPrompt', () => { + beforeEach(() => { + vi.clearAllMocks() + const localStorageMock = { + getItem: vi.fn(() => 'en'), + setItem: vi.fn(), + removeItem: vi.fn(), + } + Object.defineProperty(window, 'localStorage', { value: localStorageMock }) + }) + + it('does not clear first hub URL edit when hub URL required', async () => { + renderWithProviders( + ({ ok: true as const, value }))} + clearServerUrl={vi.fn()} + requireServerUrl={true} + onLogin={vi.fn()} + /> + ) + + fireEvent.change(screen.getByPlaceholderText('Access token'), { target: { value: 'token' } }) + fireEvent.click(screen.getByRole('button', { name: 'Sign In' })) + + const hubInput = await screen.findByPlaceholderText('https://hapi.example.com') + expect(screen.getByText('Hub URL required. Please set it before signing in.')).toBeInTheDocument() + + fireEvent.change(hubInput, { target: { value: 'https://hub.example.com' } }) + + expect(hubInput).toHaveValue('https://hub.example.com') + expect(screen.queryByText('Hub URL required. Please set it before signing in.')).not.toBeInTheDocument() + }) +}) diff --git a/web/src/components/LoginPrompt.tsx b/web/src/components/LoginPrompt.tsx index 20356001..be569559 100644 --- a/web/src/components/LoginPrompt.tsx +++ b/web/src/components/LoginPrompt.tsx @@ -15,6 +15,7 @@ type LoginPromptProps = { serverUrl: string | null setServerUrl: (input: string) => ServerUrlResult clearServerUrl: () => void + requireServerUrl?: boolean error?: string | null } @@ -37,6 +38,12 @@ export function LoginPrompt(props: LoginPromptProps) { return } + if (!isBindMode && props.requireServerUrl && !props.serverUrl) { + setServerError(t('login.server.required')) + setIsServerDialogOpen(true) + return + } + setIsLoading(true) setError(null) @@ -71,7 +78,6 @@ export function LoginPrompt(props: LoginPromptProps) { return } setServerInput(props.serverUrl ?? '') - setServerError(null) }, [isServerDialogOpen, props.serverUrl]) const handleSaveServer = useCallback((e: React.FormEvent) => { @@ -93,6 +99,13 @@ export function LoginPrompt(props: LoginPromptProps) { setIsServerDialogOpen(false) }, [props]) + const handleServerDialogOpenChange = useCallback((open: boolean) => { + setIsServerDialogOpen(open) + if (!open) { + setServerError(null) + } + }, []) + const displayError = error || props.error const serverSummary = props.serverUrl ?? `${props.baseUrl} ${t('login.server.default')}` const title = isBindMode ? t('login.bind.title') : t('login.title') @@ -158,7 +171,7 @@ export function LoginPrompt(props: LoginPromptProps) { {t('login.help')} - +