mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
Keep H5 settings failures visible without persisting raw tokens
Limit the legacy fallback to missing H5 endpoints, preserve last known H5 settings on real load failures, and move the generated token lifetime into the General settings component so the raw token clears after copy or timeout instead of living in global state. Constraint: Task 3 remains limited to desktop settings store, UI, and tests Rejected: Treat every H5 load failure as disabled defaults | hides real outages and overwrites useful last-known state Rejected: Keep raw tokens in Zustand until manually dismissed | re-exposes secrets after reopening Settings Confidence: high Scope-risk: narrow Directive: Only 404/405 H5 endpoint misses should degrade to disabled defaults; all other H5 load failures must preserve state and surface h5AccessError Tested: cd desktop && bunx vitest run src/__tests__/generalSettings.test.tsx src/stores/settingsStore.test.ts Tested: cd desktop && bun run lint Tested: git diff --check -- desktop/src/stores/settingsStore.ts desktop/src/pages/Settings.tsx desktop/src/stores/settingsStore.test.ts desktop/src/__tests__/generalSettings.test.tsx desktop/src/i18n/locales/en.ts desktop/src/i18n/locales/zh.ts
This commit is contained in:
parent
a677163e0d
commit
4865b4f206
@ -18,6 +18,9 @@ const desktopNotificationsMock = vi.hoisted(() => ({
|
||||
requestDesktopNotificationPermission: vi.fn(),
|
||||
openDesktopNotificationSettings: vi.fn(),
|
||||
}))
|
||||
const clipboardMock = vi.hoisted(() => ({
|
||||
copyTextToClipboard: vi.fn(),
|
||||
}))
|
||||
const providerStoreState = {
|
||||
providers: [] as SavedProvider[],
|
||||
activeId: null as string | null,
|
||||
@ -54,6 +57,7 @@ vi.mock('../api/providers', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('../lib/desktopNotifications', () => desktopNotificationsMock)
|
||||
vi.mock('../components/chat/clipboard', () => clipboardMock)
|
||||
|
||||
vi.mock('../components/settings/ClaudeOfficialLogin', () => ({
|
||||
ClaudeOfficialLogin: () => <div data-testid="claude-official-login" />,
|
||||
@ -94,6 +98,7 @@ vi.mock('../components/chat/CodeViewer', () => ({
|
||||
|
||||
describe('Settings > General tab', () => {
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers()
|
||||
MOCK_DELETE_PROVIDER.mockReset()
|
||||
desktopNotificationsMock.getDesktopNotificationPermission.mockReset()
|
||||
desktopNotificationsMock.notifyDesktop.mockReset()
|
||||
@ -103,6 +108,8 @@ describe('Settings > General tab', () => {
|
||||
desktopNotificationsMock.notifyDesktop.mockResolvedValue(true)
|
||||
desktopNotificationsMock.requestDesktopNotificationPermission.mockResolvedValue('granted')
|
||||
desktopNotificationsMock.openDesktopNotificationSettings.mockResolvedValue(true)
|
||||
clipboardMock.copyTextToClipboard.mockReset()
|
||||
clipboardMock.copyTextToClipboard.mockResolvedValue(true)
|
||||
MOCK_GET_SETTINGS.mockResolvedValue({})
|
||||
MOCK_UPDATE_SETTINGS.mockResolvedValue({})
|
||||
providerStoreState.providers = []
|
||||
@ -132,7 +139,7 @@ describe('Settings > General tab', () => {
|
||||
allowedOrigins: [],
|
||||
publicBaseUrl: null,
|
||||
},
|
||||
h5AccessGeneratedToken: null,
|
||||
h5AccessError: null,
|
||||
setThinkingEnabled: vi.fn().mockImplementation(async (enabled: boolean) => {
|
||||
useSettingsStore.setState({ thinkingEnabled: enabled })
|
||||
}),
|
||||
@ -145,11 +152,10 @@ describe('Settings > General tab', () => {
|
||||
setWebSearch: vi.fn().mockImplementation(async (webSearch) => {
|
||||
useSettingsStore.setState({ webSearch })
|
||||
}),
|
||||
enableH5Access: vi.fn(),
|
||||
enableH5Access: vi.fn().mockResolvedValue('h5_default_generated_token'),
|
||||
disableH5Access: vi.fn(),
|
||||
regenerateH5AccessToken: vi.fn(),
|
||||
regenerateH5AccessToken: vi.fn().mockResolvedValue('h5_default_regenerated_token'),
|
||||
updateH5AccessSettings: vi.fn(),
|
||||
clearH5AccessGeneratedToken: vi.fn(),
|
||||
})
|
||||
|
||||
useUIStore.setState({ pendingSettingsTab: null })
|
||||
@ -280,6 +286,17 @@ describe('Settings > General tab', () => {
|
||||
allowedOrigins: ['https://phone.example'],
|
||||
publicBaseUrl: 'https://phone.example/app',
|
||||
},
|
||||
regenerateH5AccessToken: vi.fn().mockImplementation(async () => {
|
||||
useSettingsStore.setState({
|
||||
h5Access: {
|
||||
enabled: true,
|
||||
tokenPreview: 'h5d4e5f6',
|
||||
allowedOrigins: ['https://phone.example'],
|
||||
publicBaseUrl: 'https://phone.example/app',
|
||||
},
|
||||
})
|
||||
return 'h5_regenerated_secret_token'
|
||||
}),
|
||||
})
|
||||
render(<Settings />)
|
||||
|
||||
@ -293,6 +310,103 @@ describe('Settings > General tab', () => {
|
||||
expect(useSettingsStore.getState().regenerateH5AccessToken).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('copies the generated H5 token and clears it after a successful copy', async () => {
|
||||
useSettingsStore.setState({
|
||||
enableH5Access: vi.fn().mockImplementation(async () => {
|
||||
useSettingsStore.setState({
|
||||
h5Access: {
|
||||
enabled: true,
|
||||
tokenPreview: 'h5z1y2x3',
|
||||
allowedOrigins: [],
|
||||
publicBaseUrl: null,
|
||||
},
|
||||
})
|
||||
return 'h5_secret_token'
|
||||
}),
|
||||
})
|
||||
render(<Settings />)
|
||||
|
||||
fireEvent.click(screen.getByText('General'))
|
||||
const section = screen.getByRole('region', { name: 'H5 Access' })
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(within(section).getByLabelText('Enable H5 access'))
|
||||
})
|
||||
|
||||
expect(await within(section).findByText('h5_secret_token')).toBeInTheDocument()
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(within(section).getByRole('button', { name: 'Copy' }))
|
||||
})
|
||||
|
||||
expect(clipboardMock.copyTextToClipboard).toHaveBeenCalledWith('h5_secret_token')
|
||||
expect(within(section).queryByText('h5_secret_token')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('copies the H5 URL when available', async () => {
|
||||
useSettingsStore.setState({
|
||||
h5Access: {
|
||||
enabled: true,
|
||||
tokenPreview: 'h5url123',
|
||||
allowedOrigins: ['https://phone.example'],
|
||||
publicBaseUrl: 'https://phone.example/app',
|
||||
},
|
||||
})
|
||||
render(<Settings />)
|
||||
|
||||
fireEvent.click(screen.getByText('General'))
|
||||
const section = screen.getByRole('region', { name: 'H5 Access' })
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(within(section).getByRole('button', { name: 'Copy H5 URL' }))
|
||||
})
|
||||
|
||||
expect(clipboardMock.copyTextToClipboard).toHaveBeenCalledWith('https://phone.example/app')
|
||||
})
|
||||
|
||||
it('clears the generated token after the visibility timeout', async () => {
|
||||
vi.useFakeTimers()
|
||||
useSettingsStore.setState({
|
||||
enableH5Access: vi.fn().mockImplementation(async () => {
|
||||
useSettingsStore.setState({
|
||||
h5Access: {
|
||||
enabled: true,
|
||||
tokenPreview: 'h5timeout',
|
||||
allowedOrigins: [],
|
||||
publicBaseUrl: null,
|
||||
},
|
||||
})
|
||||
return 'h5_timeout_token'
|
||||
}),
|
||||
})
|
||||
render(<Settings />)
|
||||
|
||||
fireEvent.click(screen.getByText('General'))
|
||||
const section = screen.getByRole('region', { name: 'H5 Access' })
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(within(section).getByLabelText('Enable H5 access'))
|
||||
})
|
||||
|
||||
expect(within(section).getByText('h5_timeout_token')).toBeInTheDocument()
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(30_000)
|
||||
})
|
||||
|
||||
expect(within(section).queryByText('h5_timeout_token')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the H5-specific store error when the H5 settings load failed', () => {
|
||||
useSettingsStore.setState({ h5AccessError: 'H5 unavailable' })
|
||||
render(<Settings />)
|
||||
|
||||
fireEvent.click(screen.getByText('General'))
|
||||
|
||||
const section = screen.getByRole('region', { name: 'H5 Access' })
|
||||
expect(within(section).getByText('H5 unavailable')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('updates H5 public URL and allowed origins from General settings', async () => {
|
||||
useSettingsStore.setState({
|
||||
h5Access: {
|
||||
|
||||
@ -677,6 +677,7 @@ export const en = {
|
||||
'settings.general.h5AccessGeneratedToken': 'Generated token',
|
||||
'settings.general.h5AccessGeneratedTokenHint': 'Shown once. Copy it now and store it like a password.',
|
||||
'settings.general.h5AccessCopy': 'Copy',
|
||||
'settings.general.h5AccessCopyUrl': 'Copy H5 URL',
|
||||
'settings.general.h5AccessHideToken': 'Hide token',
|
||||
'settings.general.h5AccessPublicUrl': 'Public URL',
|
||||
'settings.general.h5AccessPublicUrlPlaceholder': 'https://chat.example.com',
|
||||
|
||||
@ -679,6 +679,7 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'settings.general.h5AccessGeneratedToken': '生成的令牌',
|
||||
'settings.general.h5AccessGeneratedTokenHint': '仅显示一次,请立即复制并像密码一样保存。',
|
||||
'settings.general.h5AccessCopy': '复制',
|
||||
'settings.general.h5AccessCopyUrl': '复制 H5 链接',
|
||||
'settings.general.h5AccessHideToken': '隐藏令牌',
|
||||
'settings.general.h5AccessPublicUrl': '公开访问 URL',
|
||||
'settings.general.h5AccessPublicUrlPlaceholder': 'https://chat.example.com',
|
||||
|
||||
@ -47,6 +47,8 @@ import {
|
||||
} from '../lib/providerSettingsJson'
|
||||
import { copyTextToClipboard } from '../components/chat/clipboard'
|
||||
|
||||
const H5_GENERATED_TOKEN_TIMEOUT_MS = 30_000
|
||||
|
||||
export function Settings() {
|
||||
const [activeTab, setActiveTab] = useState<SettingsTab>('providers')
|
||||
const pendingSettingsTab = useUIStore((s) => s.pendingSettingsTab)
|
||||
@ -1358,21 +1360,20 @@ function GeneralSettings() {
|
||||
webSearch,
|
||||
setWebSearch,
|
||||
h5Access,
|
||||
h5AccessGeneratedToken,
|
||||
h5AccessError,
|
||||
enableH5Access,
|
||||
disableH5Access,
|
||||
regenerateH5AccessToken,
|
||||
updateH5AccessSettings,
|
||||
clearH5AccessGeneratedToken,
|
||||
} = useSettingsStore()
|
||||
const t = useTranslation()
|
||||
const [webSearchDraft, setWebSearchDraft] = useState(webSearch)
|
||||
const [h5PublicBaseUrlDraft, setH5PublicBaseUrlDraft] = useState(h5Access.publicBaseUrl ?? '')
|
||||
const [h5AllowedOriginsDraft, setH5AllowedOriginsDraft] = useState(serializeAllowedOrigins(h5Access.allowedOrigins))
|
||||
const [generatedH5Token, setGeneratedH5Token] = useState<string | null>(null)
|
||||
const [notificationPermission, setNotificationPermission] = useState<DesktopNotificationPermission>('default')
|
||||
const [notificationActionRunning, setNotificationActionRunning] = useState(false)
|
||||
const [h5ActionRunning, setH5ActionRunning] = useState(false)
|
||||
const [h5Error, setH5Error] = useState<string | null>(null)
|
||||
const webSearchDirty = JSON.stringify(webSearchDraft) !== JSON.stringify(webSearch)
|
||||
const h5AccessUrl = h5Access.enabled && h5Access.publicBaseUrl ? h5Access.publicBaseUrl : null
|
||||
const h5AccessDirty =
|
||||
@ -1388,6 +1389,18 @@ function GeneralSettings() {
|
||||
setH5AllowedOriginsDraft(serializeAllowedOrigins(h5Access.allowedOrigins))
|
||||
}, [h5Access])
|
||||
|
||||
useEffect(() => {
|
||||
if (!generatedH5Token) return
|
||||
|
||||
const timeout = window.setTimeout(() => {
|
||||
setGeneratedH5Token(null)
|
||||
}, H5_GENERATED_TOKEN_TIMEOUT_MS)
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timeout)
|
||||
}
|
||||
}, [generatedH5Token])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
getDesktopNotificationPermission().then((permission) => {
|
||||
@ -1477,11 +1490,10 @@ function GeneralSettings() {
|
||||
|
||||
const runH5Action = async (action: () => Promise<void>) => {
|
||||
setH5ActionRunning(true)
|
||||
setH5Error(null)
|
||||
try {
|
||||
await action()
|
||||
} catch (error) {
|
||||
setH5Error(error instanceof Error ? error.message : t('settings.general.h5AccessError'))
|
||||
} catch {
|
||||
// The store owns H5-specific error state.
|
||||
} finally {
|
||||
setH5ActionRunning(false)
|
||||
}
|
||||
@ -1490,10 +1502,12 @@ function GeneralSettings() {
|
||||
const handleH5AccessToggle = async (enabled: boolean) => {
|
||||
await runH5Action(async () => {
|
||||
if (enabled) {
|
||||
await enableH5Access()
|
||||
const token = await enableH5Access()
|
||||
setGeneratedH5Token(token)
|
||||
return
|
||||
}
|
||||
|
||||
setGeneratedH5Token(null)
|
||||
await disableH5Access()
|
||||
})
|
||||
}
|
||||
@ -1507,8 +1521,17 @@ function GeneralSettings() {
|
||||
})
|
||||
}
|
||||
|
||||
const handleH5Copy = async (value: string) => {
|
||||
await copyTextToClipboard(value)
|
||||
const handleGeneratedH5TokenCopy = async () => {
|
||||
if (!generatedH5Token) return
|
||||
const copied = await copyTextToClipboard(generatedH5Token)
|
||||
if (copied) {
|
||||
setGeneratedH5Token(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleH5UrlCopy = async () => {
|
||||
if (!h5AccessUrl) return
|
||||
await copyTextToClipboard(h5AccessUrl)
|
||||
}
|
||||
|
||||
return (
|
||||
@ -1682,7 +1705,10 @@ function GeneralSettings() {
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => void runH5Action(regenerateH5AccessToken)}
|
||||
onClick={() => void runH5Action(async () => {
|
||||
const token = await regenerateH5AccessToken()
|
||||
setGeneratedH5Token(token)
|
||||
})}
|
||||
disabled={!h5Access.enabled || h5ActionRunning}
|
||||
>
|
||||
{t('settings.general.h5AccessRegenerate')}
|
||||
@ -1690,7 +1716,7 @@ function GeneralSettings() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{h5AccessGeneratedToken && (
|
||||
{generatedH5Token && (
|
||||
<div className="mt-4 border-t border-[var(--color-border)]/60 pt-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-sm font-medium text-[var(--color-text-primary)]">
|
||||
@ -1700,21 +1726,21 @@ function GeneralSettings() {
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => void handleH5Copy(h5AccessGeneratedToken)}
|
||||
onClick={() => void handleGeneratedH5TokenCopy()}
|
||||
>
|
||||
{t('settings.general.h5AccessCopy')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => clearH5AccessGeneratedToken()}
|
||||
onClick={() => setGeneratedH5Token(null)}
|
||||
>
|
||||
{t('settings.general.h5AccessHideToken')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<code className="mt-2 block rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2 text-xs text-[var(--color-text-primary)] break-all">
|
||||
{h5AccessGeneratedToken}
|
||||
{generatedH5Token}
|
||||
</code>
|
||||
<p className="mt-2 text-xs text-[var(--color-text-tertiary)]">
|
||||
{t('settings.general.h5AccessGeneratedTokenHint')}
|
||||
@ -1769,7 +1795,8 @@ function GeneralSettings() {
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="shrink-0"
|
||||
onClick={() => void handleH5Copy(h5AccessUrl)}
|
||||
aria-label={t('settings.general.h5AccessCopyUrl')}
|
||||
onClick={() => void handleH5UrlCopy()}
|
||||
>
|
||||
{t('settings.general.h5AccessCopy')}
|
||||
</Button>
|
||||
@ -1780,9 +1807,9 @@ function GeneralSettings() {
|
||||
<p className="mt-4 text-xs text-[var(--color-text-tertiary)] leading-5">
|
||||
{t('settings.general.h5AccessSafetyNote')}
|
||||
</p>
|
||||
{h5Error && (
|
||||
{h5AccessError && (
|
||||
<p className="mt-2 text-xs text-[var(--color-error)]">
|
||||
{h5Error}
|
||||
{h5AccessError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ApiError } from '../api/client'
|
||||
|
||||
describe('settingsStore locale defaults', () => {
|
||||
beforeEach(() => {
|
||||
@ -47,6 +48,22 @@ describe('settingsStore desktop notification persistence', () => {
|
||||
setEffort: vi.fn(),
|
||||
},
|
||||
}))
|
||||
vi.doMock('../api/h5Access', () => ({
|
||||
h5AccessApi: {
|
||||
get: vi.fn().mockResolvedValue({
|
||||
settings: {
|
||||
enabled: false,
|
||||
tokenPreview: null,
|
||||
allowedOrigins: [],
|
||||
publicBaseUrl: null,
|
||||
},
|
||||
}),
|
||||
enable: vi.fn(),
|
||||
disable: vi.fn(),
|
||||
regenerate: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
const { useSettingsStore } = await import('./settingsStore')
|
||||
|
||||
@ -72,6 +89,22 @@ describe('settingsStore desktop notification persistence', () => {
|
||||
setEffort: vi.fn(),
|
||||
},
|
||||
}))
|
||||
vi.doMock('../api/h5Access', () => ({
|
||||
h5AccessApi: {
|
||||
get: vi.fn().mockResolvedValue({
|
||||
settings: {
|
||||
enabled: false,
|
||||
tokenPreview: null,
|
||||
allowedOrigins: [],
|
||||
publicBaseUrl: null,
|
||||
},
|
||||
}),
|
||||
enable: vi.fn(),
|
||||
disable: vi.fn(),
|
||||
regenerate: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
const { useSettingsStore } = await import('./settingsStore')
|
||||
|
||||
@ -107,6 +140,22 @@ describe('settingsStore desktop notification persistence', () => {
|
||||
setEffort: vi.fn(),
|
||||
},
|
||||
}))
|
||||
vi.doMock('../api/h5Access', () => ({
|
||||
h5AccessApi: {
|
||||
get: vi.fn().mockResolvedValue({
|
||||
settings: {
|
||||
enabled: false,
|
||||
tokenPreview: null,
|
||||
allowedOrigins: [],
|
||||
publicBaseUrl: null,
|
||||
},
|
||||
}),
|
||||
enable: vi.fn(),
|
||||
disable: vi.fn(),
|
||||
regenerate: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
const { useSettingsStore } = await import('./settingsStore')
|
||||
|
||||
@ -129,3 +178,192 @@ describe('settingsStore desktop notification persistence', () => {
|
||||
expect(useSettingsStore.getState().desktopNotificationsEnabled).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('settingsStore H5 access behavior', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
vi.clearAllMocks()
|
||||
window.localStorage.clear()
|
||||
})
|
||||
|
||||
it.each([404, 405])('falls back to disabled defaults only for legacy H5 endpoint status %s', async (status) => {
|
||||
vi.doMock('../api/settings', () => ({
|
||||
settingsApi: {
|
||||
getUser: vi.fn().mockResolvedValue({}),
|
||||
updateUser: vi.fn(),
|
||||
getPermissionMode: vi.fn().mockResolvedValue({ mode: 'default' }),
|
||||
setPermissionMode: vi.fn(),
|
||||
getCliLauncherStatus: vi.fn(),
|
||||
},
|
||||
}))
|
||||
vi.doMock('../api/models', () => ({
|
||||
modelsApi: {
|
||||
list: vi.fn().mockResolvedValue({ models: [] }),
|
||||
getCurrent: vi.fn().mockResolvedValue({ model: null }),
|
||||
setCurrent: vi.fn(),
|
||||
getEffort: vi.fn().mockResolvedValue({ level: 'medium' }),
|
||||
setEffort: vi.fn(),
|
||||
},
|
||||
}))
|
||||
vi.doMock('../api/h5Access', () => ({
|
||||
h5AccessApi: {
|
||||
get: vi.fn().mockRejectedValue(new ApiError(status, { message: 'legacy' })),
|
||||
enable: vi.fn(),
|
||||
disable: vi.fn(),
|
||||
regenerate: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
const { useSettingsStore } = await import('./settingsStore')
|
||||
useSettingsStore.setState({
|
||||
h5Access: {
|
||||
enabled: true,
|
||||
tokenPreview: 'h5_prev',
|
||||
allowedOrigins: ['https://prev.example'],
|
||||
publicBaseUrl: 'https://prev.example/app',
|
||||
},
|
||||
})
|
||||
|
||||
await useSettingsStore.getState().fetchAll()
|
||||
|
||||
expect(useSettingsStore.getState().h5Access).toEqual({
|
||||
enabled: false,
|
||||
tokenPreview: null,
|
||||
allowedOrigins: [],
|
||||
publicBaseUrl: null,
|
||||
})
|
||||
expect(useSettingsStore.getState().h5AccessError).toBeNull()
|
||||
})
|
||||
|
||||
it('preserves the last known H5 state and surfaces an H5 error on non-legacy load failures', async () => {
|
||||
vi.doMock('../api/settings', () => ({
|
||||
settingsApi: {
|
||||
getUser: vi.fn().mockResolvedValue({}),
|
||||
updateUser: vi.fn(),
|
||||
getPermissionMode: vi.fn().mockResolvedValue({ mode: 'default' }),
|
||||
setPermissionMode: vi.fn(),
|
||||
getCliLauncherStatus: vi.fn(),
|
||||
},
|
||||
}))
|
||||
vi.doMock('../api/models', () => ({
|
||||
modelsApi: {
|
||||
list: vi.fn().mockResolvedValue({ models: [] }),
|
||||
getCurrent: vi.fn().mockResolvedValue({ model: null }),
|
||||
setCurrent: vi.fn(),
|
||||
getEffort: vi.fn().mockResolvedValue({ level: 'medium' }),
|
||||
setEffort: vi.fn(),
|
||||
},
|
||||
}))
|
||||
vi.doMock('../api/h5Access', () => ({
|
||||
h5AccessApi: {
|
||||
get: vi.fn().mockRejectedValue(new ApiError(500, { message: 'H5 unavailable' })),
|
||||
enable: vi.fn(),
|
||||
disable: vi.fn(),
|
||||
regenerate: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
const { useSettingsStore } = await import('./settingsStore')
|
||||
useSettingsStore.setState({
|
||||
h5Access: {
|
||||
enabled: true,
|
||||
tokenPreview: 'h5_prev',
|
||||
allowedOrigins: ['https://prev.example'],
|
||||
publicBaseUrl: 'https://prev.example/app',
|
||||
},
|
||||
})
|
||||
|
||||
await useSettingsStore.getState().fetchAll()
|
||||
|
||||
expect(useSettingsStore.getState().h5Access).toEqual({
|
||||
enabled: true,
|
||||
tokenPreview: 'h5_prev',
|
||||
allowedOrigins: ['https://prev.example'],
|
||||
publicBaseUrl: 'https://prev.example/app',
|
||||
})
|
||||
expect(useSettingsStore.getState().h5AccessError).toBe('H5 unavailable')
|
||||
})
|
||||
|
||||
it('handles H5 enable, regenerate, and disable transitions without persisting a raw token in store state', async () => {
|
||||
vi.doMock('../api/settings', () => ({
|
||||
settingsApi: {
|
||||
getUser: vi.fn(),
|
||||
updateUser: vi.fn(),
|
||||
getPermissionMode: vi.fn(),
|
||||
setPermissionMode: vi.fn(),
|
||||
getCliLauncherStatus: vi.fn(),
|
||||
},
|
||||
}))
|
||||
vi.doMock('../api/models', () => ({
|
||||
modelsApi: {
|
||||
list: vi.fn(),
|
||||
getCurrent: vi.fn(),
|
||||
setCurrent: vi.fn(),
|
||||
getEffort: vi.fn(),
|
||||
setEffort: vi.fn(),
|
||||
},
|
||||
}))
|
||||
vi.doMock('../api/h5Access', () => ({
|
||||
h5AccessApi: {
|
||||
get: vi.fn(),
|
||||
enable: vi.fn().mockResolvedValue({
|
||||
settings: {
|
||||
enabled: true,
|
||||
tokenPreview: 'h5_first',
|
||||
allowedOrigins: [],
|
||||
publicBaseUrl: null,
|
||||
},
|
||||
token: 'raw-enable-token',
|
||||
}),
|
||||
disable: vi.fn().mockResolvedValue({
|
||||
settings: {
|
||||
enabled: false,
|
||||
tokenPreview: null,
|
||||
allowedOrigins: [],
|
||||
publicBaseUrl: null,
|
||||
},
|
||||
}),
|
||||
regenerate: vi.fn().mockResolvedValue({
|
||||
settings: {
|
||||
enabled: true,
|
||||
tokenPreview: 'h5_second',
|
||||
allowedOrigins: ['https://phone.example'],
|
||||
publicBaseUrl: 'https://phone.example/app',
|
||||
},
|
||||
token: 'raw-regenerated-token',
|
||||
}),
|
||||
update: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
const { useSettingsStore } = await import('./settingsStore')
|
||||
|
||||
await expect(useSettingsStore.getState().enableH5Access()).resolves.toBe('raw-enable-token')
|
||||
expect(useSettingsStore.getState().h5Access).toEqual({
|
||||
enabled: true,
|
||||
tokenPreview: 'h5_first',
|
||||
allowedOrigins: [],
|
||||
publicBaseUrl: null,
|
||||
})
|
||||
|
||||
await expect(useSettingsStore.getState().regenerateH5AccessToken()).resolves.toBe('raw-regenerated-token')
|
||||
expect(useSettingsStore.getState().h5Access).toEqual({
|
||||
enabled: true,
|
||||
tokenPreview: 'h5_second',
|
||||
allowedOrigins: ['https://phone.example'],
|
||||
publicBaseUrl: 'https://phone.example/app',
|
||||
})
|
||||
|
||||
await expect(useSettingsStore.getState().disableH5Access()).resolves.toBeUndefined()
|
||||
expect(useSettingsStore.getState().h5Access).toEqual({
|
||||
enabled: false,
|
||||
tokenPreview: null,
|
||||
allowedOrigins: [],
|
||||
publicBaseUrl: null,
|
||||
})
|
||||
expect(useSettingsStore.getState().h5AccessError).toBeNull()
|
||||
expect('h5AccessGeneratedToken' in useSettingsStore.getState()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { create } from 'zustand'
|
||||
import { ApiError } from '../api/client'
|
||||
import { settingsApi } from '../api/settings'
|
||||
import { modelsApi } from '../api/models'
|
||||
import { h5AccessApi } from '../api/h5Access'
|
||||
@ -30,7 +31,7 @@ type SettingsStore = {
|
||||
desktopNotificationsEnabled: boolean
|
||||
webSearch: WebSearchSettings
|
||||
h5Access: H5AccessSettings
|
||||
h5AccessGeneratedToken: string | null
|
||||
h5AccessError: string | null
|
||||
isLoading: boolean
|
||||
error: string | null
|
||||
|
||||
@ -45,14 +46,13 @@ type SettingsStore = {
|
||||
setSkipWebFetchPreflight: (enabled: boolean) => Promise<void>
|
||||
setDesktopNotificationsEnabled: (enabled: boolean) => Promise<void>
|
||||
setWebSearch: (settings: WebSearchSettings) => Promise<void>
|
||||
enableH5Access: () => Promise<void>
|
||||
enableH5Access: () => Promise<string>
|
||||
disableH5Access: () => Promise<void>
|
||||
regenerateH5AccessToken: () => Promise<void>
|
||||
regenerateH5AccessToken: () => Promise<string>
|
||||
updateH5AccessSettings: (input: {
|
||||
allowedOrigins?: string[]
|
||||
publicBaseUrl?: string | null
|
||||
}) => Promise<void>
|
||||
clearH5AccessGeneratedToken: () => void
|
||||
}
|
||||
|
||||
const DEFAULT_H5_ACCESS_SETTINGS: H5AccessSettings = {
|
||||
@ -75,20 +75,21 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
|
||||
desktopNotificationsEnabled: false,
|
||||
webSearch: { mode: 'auto', tavilyApiKey: '', braveApiKey: '' },
|
||||
h5Access: DEFAULT_H5_ACCESS_SETTINGS,
|
||||
h5AccessGeneratedToken: null,
|
||||
h5AccessError: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
|
||||
fetchAll: async () => {
|
||||
set({ isLoading: true, error: null })
|
||||
try {
|
||||
const [{ mode }, modelsRes, { model }, { level }, userSettings, h5AccessRes] = await Promise.all([
|
||||
const previousH5Access = get().h5Access
|
||||
const [{ mode }, modelsRes, { model }, { level }, userSettings, h5AccessResult] = await Promise.all([
|
||||
settingsApi.getPermissionMode(),
|
||||
modelsApi.list(),
|
||||
modelsApi.getCurrent(),
|
||||
modelsApi.getEffort(),
|
||||
settingsApi.getUser(),
|
||||
h5AccessApi.get().catch(() => ({ settings: DEFAULT_H5_ACCESS_SETTINGS })),
|
||||
loadH5AccessSettings(previousH5Access),
|
||||
])
|
||||
const theme = userSettings.theme === 'dark' ? 'dark' : 'light'
|
||||
useUIStore.getState().setTheme(theme)
|
||||
@ -103,8 +104,8 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
|
||||
skipWebFetchPreflight: userSettings.skipWebFetchPreflight !== false,
|
||||
desktopNotificationsEnabled: userSettings.desktopNotificationsEnabled === true,
|
||||
webSearch: normalizeWebSearchSettings(userSettings.webSearch),
|
||||
h5Access: normalizeH5AccessSettings(h5AccessRes.settings),
|
||||
h5AccessGeneratedToken: null,
|
||||
h5Access: h5AccessResult.settings,
|
||||
h5AccessError: h5AccessResult.error,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
@ -117,11 +118,8 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
|
||||
},
|
||||
|
||||
fetchH5Access: async () => {
|
||||
const { settings } = await h5AccessApi.get()
|
||||
set({
|
||||
h5Access: normalizeH5AccessSettings(settings),
|
||||
h5AccessGeneratedToken: null,
|
||||
})
|
||||
const result = await loadH5AccessSettings(get().h5Access)
|
||||
set({ h5Access: result.settings, h5AccessError: result.error })
|
||||
},
|
||||
|
||||
setPermissionMode: async (mode) => {
|
||||
@ -220,38 +218,61 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
|
||||
},
|
||||
|
||||
enableH5Access: async () => {
|
||||
const { settings, token } = await h5AccessApi.enable()
|
||||
set({
|
||||
h5Access: normalizeH5AccessSettings(settings),
|
||||
h5AccessGeneratedToken: token,
|
||||
})
|
||||
set({ h5AccessError: null })
|
||||
try {
|
||||
const { settings, token } = await h5AccessApi.enable()
|
||||
set({
|
||||
h5Access: normalizeH5AccessSettings(settings),
|
||||
h5AccessError: null,
|
||||
})
|
||||
return token
|
||||
} catch (error) {
|
||||
set({ h5AccessError: getErrorMessage(error, 'Failed to enable H5 access.') })
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
disableH5Access: async () => {
|
||||
const { settings } = await h5AccessApi.disable()
|
||||
set({
|
||||
h5Access: normalizeH5AccessSettings(settings),
|
||||
h5AccessGeneratedToken: null,
|
||||
})
|
||||
set({ h5AccessError: null })
|
||||
try {
|
||||
const { settings } = await h5AccessApi.disable()
|
||||
set({
|
||||
h5Access: normalizeH5AccessSettings(settings),
|
||||
h5AccessError: null,
|
||||
})
|
||||
} catch (error) {
|
||||
set({ h5AccessError: getErrorMessage(error, 'Failed to disable H5 access.') })
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
regenerateH5AccessToken: async () => {
|
||||
const { settings, token } = await h5AccessApi.regenerate()
|
||||
set({
|
||||
h5Access: normalizeH5AccessSettings(settings),
|
||||
h5AccessGeneratedToken: token,
|
||||
})
|
||||
set({ h5AccessError: null })
|
||||
try {
|
||||
const { settings, token } = await h5AccessApi.regenerate()
|
||||
set({
|
||||
h5Access: normalizeH5AccessSettings(settings),
|
||||
h5AccessError: null,
|
||||
})
|
||||
return token
|
||||
} catch (error) {
|
||||
set({ h5AccessError: getErrorMessage(error, 'Failed to regenerate the H5 token.') })
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
updateH5AccessSettings: async (input) => {
|
||||
const { settings } = await h5AccessApi.update(input)
|
||||
set({
|
||||
h5Access: normalizeH5AccessSettings(settings),
|
||||
})
|
||||
},
|
||||
|
||||
clearH5AccessGeneratedToken: () => {
|
||||
set({ h5AccessGeneratedToken: null })
|
||||
set({ h5AccessError: null })
|
||||
try {
|
||||
const { settings } = await h5AccessApi.update(input)
|
||||
set({
|
||||
h5Access: normalizeH5AccessSettings(settings),
|
||||
h5AccessError: null,
|
||||
})
|
||||
} catch (error) {
|
||||
set({ h5AccessError: getErrorMessage(error, 'Failed to update H5 access settings.') })
|
||||
throw error
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
@ -271,3 +292,41 @@ function normalizeH5AccessSettings(settings: H5AccessSettings | undefined): H5Ac
|
||||
publicBaseUrl: settings?.publicBaseUrl ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
async function loadH5AccessSettings(previousH5Access: H5AccessSettings): Promise<{
|
||||
settings: H5AccessSettings
|
||||
error: string | null
|
||||
}> {
|
||||
try {
|
||||
const { settings } = await h5AccessApi.get()
|
||||
return {
|
||||
settings: normalizeH5AccessSettings(settings),
|
||||
error: null,
|
||||
}
|
||||
} catch (error) {
|
||||
if (isLegacyH5EndpointError(error)) {
|
||||
return {
|
||||
settings: DEFAULT_H5_ACCESS_SETTINGS,
|
||||
error: null,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
settings: previousH5Access,
|
||||
error: getErrorMessage(error, 'Failed to load H5 access settings.'),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isLegacyH5EndpointError(error: unknown) {
|
||||
const status = error instanceof ApiError
|
||||
? error.status
|
||||
: typeof error === 'object' && error !== null && 'status' in error && typeof error.status === 'number'
|
||||
? error.status
|
||||
: null
|
||||
return status === 404 || status === 405
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown, fallback: string) {
|
||||
return error instanceof Error && error.message.trim().length > 0 ? error.message : fallback
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user