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:
程序员阿江(Relakkes) 2026-05-10 00:14:34 +08:00
parent a677163e0d
commit 4865b4f206
6 changed files with 498 additions and 58 deletions

View File

@ -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: {

View File

@ -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',

View File

@ -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',

View File

@ -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>

View File

@ -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)
})
})

View File

@ -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
}