mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
Add token QR workflow for H5 LAN access
H5 LAN access now uses an explicit desktop confirmation, then shows a QR launch URL that carries both the serverUrl and one-time-visible H5 token. Browser startup consumes that token from the QR URL, verifies it before storing, and requires auth for non-loopback H5 endpoints while preserving local desktop bootstrap behavior. Constraint: LAN H5 exposes desktop capabilities and must be opt-in with a visible warning. Constraint: QR launch has to preserve routed public URLs, so the link carries serverUrl as well as h5Token. Rejected: Store the raw token in Zustand | it would make accidental UI diagnostics or snapshots more likely to leak it Confidence: high Scope-risk: moderate Directive: Do not remove the serverUrl query parameter from QR links without testing non-root publicBaseUrl deployments Tested: cd desktop && bun run test src/lib/desktopRuntime.test.ts src/__tests__/generalSettings.test.tsx Tested: cd desktop && bun run lint Tested: bun run check:desktop
This commit is contained in:
parent
4ef9729ad6
commit
417e8eaa4f
@ -58,6 +58,11 @@ vi.mock('../api/providers', () => ({
|
||||
|
||||
vi.mock('../lib/desktopNotifications', () => desktopNotificationsMock)
|
||||
vi.mock('../components/chat/clipboard', () => clipboardMock)
|
||||
vi.mock('qrcode', () => ({
|
||||
default: {
|
||||
toDataURL: vi.fn().mockResolvedValue('data:image/png;base64,h5qr'),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../components/settings/ClaudeOfficialLogin', () => ({
|
||||
ClaudeOfficialLogin: () => <div data-testid="claude-official-login" />,
|
||||
@ -160,9 +165,38 @@ describe('Settings > General tab', () => {
|
||||
setWebSearch: vi.fn().mockImplementation(async (webSearch) => {
|
||||
useSettingsStore.setState({ webSearch })
|
||||
}),
|
||||
enableH5Access: vi.fn().mockResolvedValue('h5_default_generated_token'),
|
||||
disableH5Access: vi.fn(),
|
||||
regenerateH5AccessToken: vi.fn().mockResolvedValue('h5_default_regenerated_token'),
|
||||
enableH5Access: vi.fn().mockImplementation(async () => {
|
||||
const current = useSettingsStore.getState().h5Access
|
||||
useSettingsStore.setState({
|
||||
h5Access: {
|
||||
...current,
|
||||
enabled: true,
|
||||
tokenPreview: 'h5_default_generated_token'.slice(0, 8),
|
||||
},
|
||||
})
|
||||
return 'h5_default_generated_token'
|
||||
}),
|
||||
disableH5Access: vi.fn().mockImplementation(async () => {
|
||||
const current = useSettingsStore.getState().h5Access
|
||||
useSettingsStore.setState({
|
||||
h5Access: {
|
||||
...current,
|
||||
enabled: false,
|
||||
tokenPreview: null,
|
||||
},
|
||||
})
|
||||
}),
|
||||
regenerateH5AccessToken: vi.fn().mockImplementation(async () => {
|
||||
const current = useSettingsStore.getState().h5Access
|
||||
useSettingsStore.setState({
|
||||
h5Access: {
|
||||
...current,
|
||||
enabled: true,
|
||||
tokenPreview: 'h5_default_regenerated_token'.slice(0, 8),
|
||||
},
|
||||
})
|
||||
return 'h5_default_regenerated_token'
|
||||
}),
|
||||
updateH5AccessSettings: vi.fn(),
|
||||
})
|
||||
|
||||
@ -291,19 +325,99 @@ describe('Settings > General tab', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('renders the H5 section without token controls while access is open', () => {
|
||||
it('renders the H5 section with access disabled by default', () => {
|
||||
render(<Settings />)
|
||||
|
||||
fireEvent.click(screen.getByText('General'))
|
||||
|
||||
const section = screen.getByRole('region', { name: 'H5 Access' })
|
||||
expect(within(section).getByText('Token access is temporarily disabled; H5 requests are allowed without an Authorization header.')).toBeInTheDocument()
|
||||
expect(within(section).queryByLabelText('Enable H5 access')).not.toBeInTheDocument()
|
||||
expect(within(section).getByLabelText('Enable H5 access')).not.toBeChecked()
|
||||
expect(within(section).getByText('Disabled')).toBeInTheDocument()
|
||||
expect(within(section).queryByText('Token preview')).not.toBeInTheDocument()
|
||||
expect(within(section).queryByRole('button', { name: 'Regenerate token' })).not.toBeInTheDocument()
|
||||
expect(within(section).queryByLabelText('Allowed origins')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('confirms the LAN risk before enabling H5 access and renders a token QR link', async () => {
|
||||
useSettingsStore.setState({
|
||||
h5Access: {
|
||||
enabled: false,
|
||||
tokenPreview: null,
|
||||
allowedOrigins: [],
|
||||
publicBaseUrl: 'http://192.168.0.102:3456',
|
||||
},
|
||||
})
|
||||
render(<Settings />)
|
||||
|
||||
fireEvent.click(screen.getByText('General'))
|
||||
const section = screen.getByRole('region', { name: 'H5 Access' })
|
||||
|
||||
fireEvent.click(within(section).getByLabelText('Enable H5 access'))
|
||||
const dialog = screen.getByRole('dialog', { name: 'Enable LAN H5 access?' })
|
||||
expect(within(dialog).getByText(/desktop H5 app on your LAN address and port/i)).toBeInTheDocument()
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Enable H5 access' }))
|
||||
})
|
||||
|
||||
expect(useSettingsStore.getState().enableH5Access).toHaveBeenCalledTimes(1)
|
||||
expect(await within(section).findByAltText('H5 access QR code')).toBeInTheDocument()
|
||||
expect(within(section).getByText('http://192.168.0.102:3456/?serverUrl=http%3A%2F%2F192.168.0.102%3A3456&h5Token=h5_default_generated_token')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('copies the QR launch URL with the generated H5 token', async () => {
|
||||
useSettingsStore.setState({
|
||||
h5Access: {
|
||||
enabled: false,
|
||||
tokenPreview: null,
|
||||
allowedOrigins: [],
|
||||
publicBaseUrl: 'http://192.168.0.102:3456',
|
||||
},
|
||||
})
|
||||
render(<Settings />)
|
||||
|
||||
fireEvent.click(screen.getByText('General'))
|
||||
const section = screen.getByRole('region', { name: 'H5 Access' })
|
||||
fireEvent.click(within(section).getByLabelText('Enable H5 access'))
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(within(screen.getByRole('dialog', { name: 'Enable LAN H5 access?' })).getByRole('button', { name: 'Enable H5 access' }))
|
||||
})
|
||||
|
||||
await within(section).findByAltText('H5 access QR code')
|
||||
await act(async () => {
|
||||
fireEvent.click(within(section).getByRole('button', { name: 'Copy QR link' }))
|
||||
})
|
||||
|
||||
expect(clipboardMock.copyTextToClipboard).toHaveBeenCalledWith(
|
||||
'http://192.168.0.102:3456/?serverUrl=http%3A%2F%2F192.168.0.102%3A3456&h5Token=h5_default_generated_token',
|
||||
)
|
||||
})
|
||||
|
||||
it('shows the generated H5 token as a fallback when requested', async () => {
|
||||
useSettingsStore.setState({
|
||||
h5Access: {
|
||||
enabled: false,
|
||||
tokenPreview: null,
|
||||
allowedOrigins: [],
|
||||
publicBaseUrl: 'http://192.168.0.102:3456',
|
||||
},
|
||||
})
|
||||
render(<Settings />)
|
||||
|
||||
fireEvent.click(screen.getByText('General'))
|
||||
const section = screen.getByRole('region', { name: 'H5 Access' })
|
||||
fireEvent.click(within(section).getByLabelText('Enable H5 access'))
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(within(screen.getByRole('dialog', { name: 'Enable LAN H5 access?' })).getByRole('button', { name: 'Enable H5 access' }))
|
||||
})
|
||||
|
||||
fireEvent.click(within(section).getByRole('button', { name: 'Show token' }))
|
||||
|
||||
expect(within(section).getByText('h5_default_generated_token')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('places H5 access after the common General settings sections', () => {
|
||||
render(<Settings />)
|
||||
|
||||
@ -317,7 +431,7 @@ describe('Settings > General tab', () => {
|
||||
it('copies the H5 URL when available', async () => {
|
||||
useSettingsStore.setState({
|
||||
h5Access: {
|
||||
enabled: false,
|
||||
enabled: true,
|
||||
tokenPreview: 'h5url123',
|
||||
allowedOrigins: ['https://phone.example'],
|
||||
publicBaseUrl: 'https://phone.example/app',
|
||||
|
||||
@ -711,26 +711,38 @@ export const en = {
|
||||
'settings.general.notificationsTestTitle': 'Claude Code Haha notifications are enabled',
|
||||
'settings.general.notificationsTestBody': 'Permission prompts and completed agent replies will now use system notifications.',
|
||||
'settings.general.h5AccessTitle': 'H5 Access',
|
||||
'settings.general.h5AccessDescription': 'Browser access is open while the token gate is paused. Configure the public URL only when you expose the local service through a stable address.',
|
||||
'settings.general.h5AccessDescription': 'Expose the desktop H5 app on your local network. Phones connect with a QR link that includes a short-lived H5 token.',
|
||||
'settings.general.h5AccessEnabled': 'Enable H5 access',
|
||||
'settings.general.h5AccessEnabledHint': 'Turn this on only for networks and browser origins you control.',
|
||||
'settings.general.h5AccessEnabledHint': 'The desktop server listens on the LAN address and allows access to desktop sessions.',
|
||||
'settings.general.h5AccessStatusEnabled': 'Enabled',
|
||||
'settings.general.h5AccessTokenPreview': 'Token preview',
|
||||
'settings.general.h5AccessDisabledValue': 'Disabled',
|
||||
'settings.general.h5AccessDisable': 'Disable',
|
||||
'settings.general.h5AccessRegenerate': 'Regenerate token',
|
||||
'settings.general.h5AccessGeneratedToken': 'Generated token',
|
||||
'settings.general.h5AccessGeneratedTokenHint': 'Shown once. Copy it now and store it like a password.',
|
||||
'settings.general.h5AccessShowToken': 'Show token',
|
||||
'settings.general.h5AccessCopy': 'Copy',
|
||||
'settings.general.h5AccessCopyUrl': 'Copy H5 URL',
|
||||
'settings.general.h5AccessCopyLaunchUrl': 'Copy QR link',
|
||||
'settings.general.h5AccessHideToken': 'Hide token',
|
||||
'settings.general.h5AccessTokenNotAvailable': 'Regenerate to show a new token.',
|
||||
'settings.general.h5AccessPublicUrl': 'Public URL',
|
||||
'settings.general.h5AccessPublicUrlPlaceholder': 'https://chat.example.com',
|
||||
'settings.general.h5AccessAllowedOrigins': 'Allowed origins',
|
||||
'settings.general.h5AccessAllowedOriginsPlaceholder': 'https://chat.example.com, https://phone.example',
|
||||
'settings.general.h5AccessOriginsHint': 'Enter one origin per line or separate multiple origins with commas.',
|
||||
'settings.general.h5AccessOpenHint': 'Token access is temporarily disabled; H5 requests are allowed without an Authorization header.',
|
||||
'settings.general.h5AccessOpenHint': 'Leave blank to use the detected LAN URL. Set this only when you expose H5 through a stable address.',
|
||||
'settings.general.h5AccessSave': 'Save H5 settings',
|
||||
'settings.general.h5AccessUrl': 'H5 URL',
|
||||
'settings.general.h5AccessSafetyNote': 'Only expose this URL on networks and devices you trust until token access is restored.',
|
||||
'settings.general.h5AccessQrTitle': 'Phone connection',
|
||||
'settings.general.h5AccessQrHint': 'Scan to open H5 with the token already filled in.',
|
||||
'settings.general.h5AccessQrRefreshHint': 'Regenerate the token to create a QR link that can be scanned.',
|
||||
'settings.general.h5AccessQrAlt': 'H5 access QR code',
|
||||
'settings.general.h5AccessSafetyNote': 'Only enable this on trusted networks. Anyone with the QR link can reach the desktop capabilities exposed by H5.',
|
||||
'settings.general.h5AccessConfirmTitle': 'Enable LAN H5 access?',
|
||||
'settings.general.h5AccessConfirmBody': 'This will expose the desktop H5 app on your LAN address and port. Devices with the QR token can access desktop sessions and related controls. Continue only on a trusted network.',
|
||||
'settings.general.h5AccessConfirmEnable': 'Enable H5 access',
|
||||
'settings.general.h5AccessError': 'Failed to update H5 access settings.',
|
||||
'settings.general.webFetchPreflightTitle': 'WebFetch Preflight',
|
||||
'settings.general.webFetchPreflightDescription': 'Desktop sessions skip Claude\'s domain preflight by default to avoid false failures on third-party providers and restricted networks.',
|
||||
|
||||
@ -713,26 +713,38 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'settings.general.notificationsTestTitle': 'Claude Code Haha 通知已启用',
|
||||
'settings.general.notificationsTestBody': '后续授权确认和 Agent 回复完成都会通过系统通知提醒。',
|
||||
'settings.general.h5AccessTitle': 'H5 访问',
|
||||
'settings.general.h5AccessDescription': 'H5 token 鉴权已临时暂停,浏览器访问会直接放行。只有需要固定外部访问地址时才配置公开 URL。',
|
||||
'settings.general.h5AccessDescription': '在局域网内暴露桌面端 H5 应用。手机通过带 H5 token 的二维码快速连接。',
|
||||
'settings.general.h5AccessEnabled': '启用 H5 访问',
|
||||
'settings.general.h5AccessEnabledHint': '只应在你可控的网络和浏览器来源上开启。',
|
||||
'settings.general.h5AccessEnabledHint': '桌面服务会监听局域网地址,并开放桌面会话相关能力。',
|
||||
'settings.general.h5AccessStatusEnabled': '已启用',
|
||||
'settings.general.h5AccessTokenPreview': '令牌预览',
|
||||
'settings.general.h5AccessDisabledValue': '未启用',
|
||||
'settings.general.h5AccessDisable': '关闭',
|
||||
'settings.general.h5AccessRegenerate': '重新生成令牌',
|
||||
'settings.general.h5AccessGeneratedToken': '生成的令牌',
|
||||
'settings.general.h5AccessGeneratedTokenHint': '仅显示一次,请立即复制并像密码一样保存。',
|
||||
'settings.general.h5AccessShowToken': '显示令牌',
|
||||
'settings.general.h5AccessCopy': '复制',
|
||||
'settings.general.h5AccessCopyUrl': '复制 H5 链接',
|
||||
'settings.general.h5AccessCopyLaunchUrl': '复制扫码链接',
|
||||
'settings.general.h5AccessHideToken': '隐藏令牌',
|
||||
'settings.general.h5AccessTokenNotAvailable': '重新生成后可显示新的令牌。',
|
||||
'settings.general.h5AccessPublicUrl': '公开访问 URL',
|
||||
'settings.general.h5AccessPublicUrlPlaceholder': 'https://chat.example.com',
|
||||
'settings.general.h5AccessAllowedOrigins': '允许的来源',
|
||||
'settings.general.h5AccessAllowedOriginsPlaceholder': 'https://chat.example.com, https://phone.example',
|
||||
'settings.general.h5AccessOriginsHint': '每行一个来源,或使用逗号分隔多个来源。',
|
||||
'settings.general.h5AccessOpenHint': '当前不会要求 Authorization header,H5 请求会直接放行。',
|
||||
'settings.general.h5AccessOpenHint': '留空时使用自动探测的局域网地址。只有通过固定地址暴露 H5 时才需要填写。',
|
||||
'settings.general.h5AccessSave': '保存 H5 设置',
|
||||
'settings.general.h5AccessUrl': 'H5 链接',
|
||||
'settings.general.h5AccessSafetyNote': '恢复 token 鉴权前,只在你信任的网络和设备上暴露这个地址。',
|
||||
'settings.general.h5AccessQrTitle': '手机连接',
|
||||
'settings.general.h5AccessQrHint': '扫码会直接打开 H5,并自动带上 token。',
|
||||
'settings.general.h5AccessQrRefreshHint': '重新生成 token 后会创建可扫码的连接。',
|
||||
'settings.general.h5AccessQrAlt': 'H5 访问二维码',
|
||||
'settings.general.h5AccessSafetyNote': '只在可信网络中启用。拿到二维码链接的人可以访问 H5 暴露的桌面能力。',
|
||||
'settings.general.h5AccessConfirmTitle': '启用局域网 H5 访问?',
|
||||
'settings.general.h5AccessConfirmBody': '这会把桌面 H5 应用暴露到局域网地址和端口。持有二维码 token 的设备可以访问桌面会话和相关控制能力。请只在可信网络中继续。',
|
||||
'settings.general.h5AccessConfirmEnable': '启用 H5 访问',
|
||||
'settings.general.h5AccessError': '更新 H5 设置失败。',
|
||||
'settings.general.webFetchPreflightTitle': 'WebFetch 预检',
|
||||
'settings.general.webFetchPreflightDescription': '桌面端默认跳过 Claude 的域名预检,避免第三方服务商或受限网络下出现误报失败。',
|
||||
|
||||
@ -52,16 +52,16 @@ describe('desktopRuntime browser H5 bootstrap', () => {
|
||||
expect(requiresH5AuthForServerUrl('http://[::1]:3456')).toBe(false)
|
||||
expect(requiresH5AuthForServerUrl('http://127.0.0.1:3456')).toBe(false)
|
||||
expect(requiresH5AuthForServerUrl('http://localhost:3456')).toBe(false)
|
||||
expect(requiresH5AuthForServerUrl('https://public.example.com/app')).toBe(false)
|
||||
expect(requiresH5AuthForServerUrl('https://public.example.com/app', 'phone.example.test')).toBe(false)
|
||||
expect(requiresH5AuthForServerUrl('https://public.example.com/app')).toBe(true)
|
||||
expect(requiresH5AuthForServerUrl('https://public.example.com/app', 'phone.example.test')).toBe(true)
|
||||
})
|
||||
|
||||
it('does not require H5 auth for LAN or public browser URLs while token access is paused', () => {
|
||||
expect(requiresH5AuthForServerUrl('http://192.168.0.102:28670', '127.0.0.1')).toBe(false)
|
||||
expect(requiresH5AuthForServerUrl('http://10.0.0.5:28670', 'localhost')).toBe(false)
|
||||
expect(requiresH5AuthForServerUrl('http://172.20.1.8:28670', 'localhost')).toBe(false)
|
||||
expect(requiresH5AuthForServerUrl('https://public.example.com/app', 'localhost')).toBe(false)
|
||||
expect(requiresH5AuthForServerUrl('http://192.168.0.102:28670', 'phone.example.test')).toBe(false)
|
||||
it('requires H5 auth for LAN and public browser URLs', () => {
|
||||
expect(requiresH5AuthForServerUrl('http://192.168.0.102:28670', '127.0.0.1')).toBe(true)
|
||||
expect(requiresH5AuthForServerUrl('http://10.0.0.5:28670', 'localhost')).toBe(true)
|
||||
expect(requiresH5AuthForServerUrl('http://172.20.1.8:28670', 'localhost')).toBe(true)
|
||||
expect(requiresH5AuthForServerUrl('https://public.example.com/app', 'localhost')).toBe(true)
|
||||
expect(requiresH5AuthForServerUrl('http://192.168.0.102:28670', 'phone.example.test')).toBe(true)
|
||||
})
|
||||
|
||||
it('clears an invalid token but preserves the remembered remote server URL', async () => {
|
||||
@ -171,38 +171,37 @@ describe('desktopRuntime browser H5 bootstrap', () => {
|
||||
expect(window.localStorage.getItem(H5_TOKEN_STORAGE_KEY)).toBeNull()
|
||||
})
|
||||
|
||||
it('lets localhost browser WebUI connect to a LAN-bound server without H5 token', async () => {
|
||||
it('requires a token when browser WebUI connects to a LAN-bound server', async () => {
|
||||
window.history.pushState({}, '', '/?serverUrl=http%3A%2F%2F192.168.0.102%3A28670')
|
||||
globalThis.fetch = vi.fn().mockResolvedValue(
|
||||
new Response(null, { status: 200 }),
|
||||
) as typeof fetch
|
||||
|
||||
await expect(initializeDesktopServerUrl()).resolves.toBe('http://192.168.0.102:28670')
|
||||
await expect(initializeDesktopServerUrl()).rejects.toMatchObject({
|
||||
name: 'H5ConnectionRequiredError',
|
||||
serverUrl: 'http://192.168.0.102:28670',
|
||||
reason: 'missing-token',
|
||||
} satisfies Partial<H5ConnectionRequiredError>)
|
||||
|
||||
expect(clientMocks.setBaseUrl).toHaveBeenLastCalledWith('http://192.168.0.102:28670')
|
||||
expect(clientMocks.setAuthToken).toHaveBeenLastCalledWith(null)
|
||||
expect(clientMocks.postVerify).not.toHaveBeenCalled()
|
||||
expect(window.localStorage.getItem(H5_SERVER_URL_STORAGE_KEY)).toBeNull()
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith('http://192.168.0.102:28670/api/status', {
|
||||
cache: 'no-store',
|
||||
})
|
||||
expect(window.localStorage.getItem(H5_SERVER_URL_STORAGE_KEY)).toBe('http://192.168.0.102:28670')
|
||||
})
|
||||
|
||||
it('lets browser H5 connect to a public server URL without H5 token', async () => {
|
||||
window.history.pushState({}, '', '/?serverUrl=https%3A%2F%2Fpublic.example.com%2Fapp')
|
||||
window.localStorage.setItem(H5_TOKEN_STORAGE_KEY, 'remote-token')
|
||||
it('uses and persists an H5 token from the QR launch URL', async () => {
|
||||
window.history.pushState({}, '', '/?serverUrl=https%3A%2F%2Fpublic.example.com%2Fapp&h5Token=qr-token')
|
||||
globalThis.fetch = vi.fn().mockResolvedValue(
|
||||
new Response(null, { status: 200 }),
|
||||
) as typeof fetch
|
||||
clientMocks.postVerify.mockResolvedValueOnce({ ok: true })
|
||||
|
||||
await expect(initializeDesktopServerUrl()).resolves.toBe('https://public.example.com/app')
|
||||
|
||||
expect(clientMocks.setBaseUrl).toHaveBeenLastCalledWith('https://public.example.com/app')
|
||||
expect(clientMocks.setAuthToken).toHaveBeenLastCalledWith(null)
|
||||
expect(clientMocks.postVerify).not.toHaveBeenCalled()
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith('https://public.example.com/app/api/status', {
|
||||
cache: 'no-store',
|
||||
})
|
||||
expect(clientMocks.setAuthToken).toHaveBeenLastCalledWith('qr-token')
|
||||
expect(clientMocks.postVerify).toHaveBeenCalledWith('/api/h5-access/verify')
|
||||
expect(window.localStorage.getItem(H5_TOKEN_STORAGE_KEY)).toBe('qr-token')
|
||||
})
|
||||
|
||||
it('shows the H5 token recovery view when a local browser connects to an auth-required LAN server', async () => {
|
||||
|
||||
@ -131,17 +131,18 @@ export async function initializeDesktopServerUrl() {
|
||||
}
|
||||
|
||||
async function initializeBrowserServerUrl(fallbackUrl: string) {
|
||||
const queryUrl =
|
||||
typeof window !== 'undefined'
|
||||
? new URLSearchParams(window.location.search).get('serverUrl')
|
||||
: null
|
||||
const query = typeof window !== 'undefined'
|
||||
? new URLSearchParams(window.location.search)
|
||||
: null
|
||||
const queryUrl = query?.get('serverUrl') ?? null
|
||||
const queryToken = normalizeToken(query?.get('h5Token') ?? query?.get('token'))
|
||||
const stored = readStoredH5Connection()
|
||||
const requestedUrl =
|
||||
normalizeServerUrl(queryUrl) ??
|
||||
stored.serverUrl ??
|
||||
getConfiguredBrowserServerUrl(fallbackUrl) ??
|
||||
fallbackUrl
|
||||
const token = stored.token
|
||||
const token = queryToken ?? stored.token
|
||||
const browserH5Runtime = requiresH5AuthForServerUrl(requestedUrl)
|
||||
|
||||
setBaseUrl(requestedUrl)
|
||||
@ -181,6 +182,14 @@ async function initializeBrowserServerUrl(fallbackUrl: string) {
|
||||
throw normalizeBrowserH5Error(error, requestedUrl)
|
||||
}
|
||||
|
||||
if (queryToken && typeof window !== 'undefined') {
|
||||
try {
|
||||
window.localStorage.setItem(H5_TOKEN_STORAGE_KEY, queryToken)
|
||||
} catch {
|
||||
// Ignore storage failures after successful verification.
|
||||
}
|
||||
}
|
||||
|
||||
return requestedUrl
|
||||
}
|
||||
|
||||
@ -269,9 +278,12 @@ export function isLoopbackHostname(hostname: string) {
|
||||
}
|
||||
|
||||
export function requiresH5AuthForServerUrl(serverUrl: string, browserHostname = getBrowserHostname()) {
|
||||
void serverUrl
|
||||
void browserHostname
|
||||
return false
|
||||
try {
|
||||
return !isLoopbackHostname(new URL(serverUrl).hostname)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function getBrowserHostname() {
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
import { useState, useEffect, useMemo, useRef, type ReactNode } from 'react'
|
||||
import QRCode from 'qrcode'
|
||||
import { Copy, Eye, EyeOff, PowerOff, QrCode, RotateCw } from 'lucide-react'
|
||||
import { useSettingsStore } from '../stores/settingsStore'
|
||||
import { useProviderStore } from '../stores/providerStore'
|
||||
import { useTranslation } from '../i18n'
|
||||
@ -47,6 +49,23 @@ import {
|
||||
} from '../lib/providerSettingsJson'
|
||||
import { copyTextToClipboard } from '../components/chat/clipboard'
|
||||
|
||||
function buildH5LaunchUrl(baseUrl: string | null, token: string | null): string | null {
|
||||
if (!baseUrl) return null
|
||||
|
||||
try {
|
||||
const url = new URL(baseUrl)
|
||||
if (token) {
|
||||
url.searchParams.set('serverUrl', baseUrl)
|
||||
url.searchParams.set('h5Token', token)
|
||||
}
|
||||
return url.toString().replace(/\/$/, '')
|
||||
} catch {
|
||||
return token
|
||||
? `${baseUrl}${baseUrl.includes('?') ? '&' : '?'}serverUrl=${encodeURIComponent(baseUrl)}&h5Token=${encodeURIComponent(token)}`
|
||||
: baseUrl
|
||||
}
|
||||
}
|
||||
|
||||
export function Settings() {
|
||||
const [activeTab, setActiveTab] = useState<SettingsTab>('providers')
|
||||
const pendingSettingsTab = useUIStore((s) => s.pendingSettingsTab)
|
||||
@ -1361,6 +1380,9 @@ function GeneralSettings() {
|
||||
setWebSearch,
|
||||
h5Access,
|
||||
h5AccessError,
|
||||
enableH5Access,
|
||||
disableH5Access,
|
||||
regenerateH5AccessToken,
|
||||
updateH5AccessSettings,
|
||||
responseLanguage,
|
||||
setResponseLanguage,
|
||||
@ -1368,11 +1390,19 @@ function GeneralSettings() {
|
||||
const t = useTranslation()
|
||||
const [webSearchDraft, setWebSearchDraft] = useState(webSearch)
|
||||
const [h5PublicBaseUrlDraft, setH5PublicBaseUrlDraft] = useState(h5Access.publicBaseUrl ?? '')
|
||||
const [h5GeneratedToken, setH5GeneratedToken] = useState<string | null>(null)
|
||||
const [h5TokenVisible, setH5TokenVisible] = useState(false)
|
||||
const [h5EnableConfirmOpen, setH5EnableConfirmOpen] = useState(false)
|
||||
const [h5QrDataUrl, setH5QrDataUrl] = useState<string | null>(null)
|
||||
const [notificationPermission, setNotificationPermission] = useState<DesktopNotificationPermission>('default')
|
||||
const [notificationActionRunning, setNotificationActionRunning] = useState(false)
|
||||
const [h5ActionRunning, setH5ActionRunning] = useState(false)
|
||||
const webSearchDirty = JSON.stringify(webSearchDraft) !== JSON.stringify(webSearch)
|
||||
const h5AccessUrl = h5Access.publicBaseUrl
|
||||
const h5LaunchUrl = useMemo(
|
||||
() => buildH5LaunchUrl(h5AccessUrl, h5GeneratedToken),
|
||||
[h5AccessUrl, h5GeneratedToken],
|
||||
)
|
||||
const h5AccessDirty = h5PublicBaseUrlDraft.trim() !== (h5Access.publicBaseUrl ?? '')
|
||||
|
||||
useEffect(() => {
|
||||
@ -1383,6 +1413,28 @@ function GeneralSettings() {
|
||||
setH5PublicBaseUrlDraft(h5Access.publicBaseUrl ?? '')
|
||||
}, [h5Access])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
if (!h5Access.enabled || !h5LaunchUrl || !h5GeneratedToken) {
|
||||
setH5QrDataUrl(null)
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}
|
||||
|
||||
QRCode.toDataURL(h5LaunchUrl, { margin: 1, width: 192 })
|
||||
.then((dataUrl) => {
|
||||
if (!cancelled) setH5QrDataUrl(dataUrl)
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setH5QrDataUrl(null)
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [h5Access.enabled, h5LaunchUrl, h5GeneratedToken])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
getDesktopNotificationPermission().then((permission) => {
|
||||
@ -1521,6 +1573,36 @@ function GeneralSettings() {
|
||||
await copyTextToClipboard(h5AccessUrl)
|
||||
}
|
||||
|
||||
const handleH5LaunchUrlCopy = async () => {
|
||||
if (!h5LaunchUrl) return
|
||||
await copyTextToClipboard(h5LaunchUrl)
|
||||
}
|
||||
|
||||
const handleH5EnableConfirm = async () => {
|
||||
await runH5Action(async () => {
|
||||
const token = await enableH5Access()
|
||||
setH5GeneratedToken(token)
|
||||
setH5TokenVisible(false)
|
||||
setH5EnableConfirmOpen(false)
|
||||
})
|
||||
}
|
||||
|
||||
const handleH5Disable = async () => {
|
||||
await runH5Action(async () => {
|
||||
await disableH5Access()
|
||||
setH5GeneratedToken(null)
|
||||
setH5TokenVisible(false)
|
||||
})
|
||||
}
|
||||
|
||||
const handleH5Regenerate = async () => {
|
||||
await runH5Action(async () => {
|
||||
const token = await regenerateH5AccessToken()
|
||||
setH5GeneratedToken(token)
|
||||
setH5TokenVisible(false)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-xl">
|
||||
{/* Appearance selector */}
|
||||
@ -1800,7 +1882,43 @@ function GeneralSettings() {
|
||||
{t('settings.general.h5AccessDescription')}
|
||||
</p>
|
||||
<div className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-4 py-4">
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<label className="flex min-w-0 items-start gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-1 h-4 w-4 rounded border-[var(--color-border)] accent-[var(--color-primary)]"
|
||||
checked={h5Access.enabled}
|
||||
disabled={h5ActionRunning}
|
||||
aria-label={t('settings.general.h5AccessEnabled')}
|
||||
onChange={(event) => {
|
||||
if (event.target.checked) {
|
||||
setH5EnableConfirmOpen(true)
|
||||
} else {
|
||||
void handleH5Disable()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm font-medium text-[var(--color-text-primary)]">
|
||||
{t('settings.general.h5AccessEnabled')}
|
||||
</span>
|
||||
<span className="mt-1 block text-xs leading-5 text-[var(--color-text-tertiary)]">
|
||||
{t('settings.general.h5AccessEnabledHint')}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
<span
|
||||
className={`shrink-0 rounded-full px-2 py-0.5 text-xs font-medium ${
|
||||
h5Access.enabled
|
||||
? 'bg-[var(--color-success)]/10 text-[var(--color-success)]'
|
||||
: 'bg-[var(--color-surface)] text-[var(--color-text-tertiary)] border border-[var(--color-border)]'
|
||||
}`}
|
||||
>
|
||||
{h5Access.enabled ? t('settings.general.h5AccessStatusEnabled') : t('settings.general.h5AccessDisabledValue')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-1 gap-3">
|
||||
<Input
|
||||
id="h5-access-public-url"
|
||||
label={t('settings.general.h5AccessPublicUrl')}
|
||||
@ -1839,6 +1957,7 @@ function GeneralSettings() {
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="shrink-0"
|
||||
icon={<Copy className="h-3.5 w-3.5" aria-hidden="true" />}
|
||||
aria-label={t('settings.general.h5AccessCopyUrl')}
|
||||
onClick={() => void handleH5UrlCopy()}
|
||||
>
|
||||
@ -1848,6 +1967,96 @@ function GeneralSettings() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{h5Access.enabled && h5AccessUrl && (
|
||||
<div className="mt-4 border-t border-[var(--color-border)]/60 pt-4">
|
||||
<div className="flex flex-col gap-4 sm:flex-row">
|
||||
<div className="flex h-48 w-48 shrink-0 items-center justify-center rounded-lg border border-[var(--color-border)] bg-white p-3">
|
||||
{h5QrDataUrl ? (
|
||||
<img
|
||||
src={h5QrDataUrl}
|
||||
alt={t('settings.general.h5AccessQrAlt')}
|
||||
className="h-full w-full"
|
||||
/>
|
||||
) : (
|
||||
<QrCode className="h-12 w-12 text-neutral-400" aria-hidden="true" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-xs font-medium uppercase text-[var(--color-text-tertiary)]">
|
||||
{t('settings.general.h5AccessQrTitle')}
|
||||
</div>
|
||||
<p className="mt-1 text-xs leading-5 text-[var(--color-text-tertiary)]">
|
||||
{h5GeneratedToken
|
||||
? t('settings.general.h5AccessQrHint')
|
||||
: t('settings.general.h5AccessQrRefreshHint')}
|
||||
</p>
|
||||
{h5LaunchUrl && (
|
||||
<div className="mt-3 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2 text-sm text-[var(--color-text-primary)] break-all">
|
||||
{h5LaunchUrl}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
icon={<Copy className="h-3.5 w-3.5" aria-hidden="true" />}
|
||||
disabled={!h5LaunchUrl || !h5GeneratedToken}
|
||||
onClick={() => void handleH5LaunchUrlCopy()}
|
||||
>
|
||||
{t('settings.general.h5AccessCopyLaunchUrl')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
icon={<RotateCw className="h-3.5 w-3.5" aria-hidden="true" />}
|
||||
loading={h5ActionRunning}
|
||||
onClick={() => void handleH5Regenerate()}
|
||||
>
|
||||
{t('settings.general.h5AccessRegenerate')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{h5Access.enabled && (
|
||||
<div className="mt-4 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs font-medium uppercase text-[var(--color-text-tertiary)]">
|
||||
{t('settings.general.h5AccessTokenPreview')}
|
||||
</div>
|
||||
<div className="mt-1 break-all text-sm text-[var(--color-text-primary)]">
|
||||
{h5TokenVisible && h5GeneratedToken
|
||||
? h5GeneratedToken
|
||||
: h5Access.tokenPreview || t('settings.general.h5AccessTokenNotAvailable')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap justify-end gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
icon={h5TokenVisible ? <EyeOff className="h-3.5 w-3.5" aria-hidden="true" /> : <Eye className="h-3.5 w-3.5" aria-hidden="true" />}
|
||||
disabled={!h5GeneratedToken}
|
||||
onClick={() => setH5TokenVisible((visible) => !visible)}
|
||||
>
|
||||
{h5TokenVisible ? t('settings.general.h5AccessHideToken') : t('settings.general.h5AccessShowToken')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
icon={<PowerOff className="h-3.5 w-3.5" aria-hidden="true" />}
|
||||
loading={h5ActionRunning}
|
||||
onClick={() => void handleH5Disable()}
|
||||
>
|
||||
{t('settings.general.h5AccessDisable')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="mt-4 text-xs text-[var(--color-text-tertiary)] leading-5">
|
||||
{t('settings.general.h5AccessSafetyNote')}
|
||||
</p>
|
||||
@ -1859,6 +2068,20 @@ function GeneralSettings() {
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={h5EnableConfirmOpen}
|
||||
onClose={() => {
|
||||
if (!h5ActionRunning) setH5EnableConfirmOpen(false)
|
||||
}}
|
||||
onConfirm={handleH5EnableConfirm}
|
||||
title={t('settings.general.h5AccessConfirmTitle')}
|
||||
body={t('settings.general.h5AccessConfirmBody')}
|
||||
confirmLabel={t('settings.general.h5AccessConfirmEnable')}
|
||||
cancelLabel={t('common.cancel')}
|
||||
confirmVariant="danger"
|
||||
loading={h5ActionRunning}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user