Make H5 access easier to discover in Settings

H5 access is a user-facing desktop sharing entrypoint, so it should not be buried inside the broad General settings surface. Move it into its own Settings tab and make the empty QR state point users at the token generation action before a scan link exists.

Constraint: Preserve the existing H5 store API and token lifecycle while changing only the desktop settings surface.
Rejected: Keep H5 access inside General with a larger visual block | still hides a strong product surface in a catch-all page.
Confidence: high
Scope-risk: narrow
Directive: Keep H5 access as a first-class settings entry unless the navigation model is redesigned holistically.
Tested: cd desktop && bun run test -- src/__tests__/generalSettings.test.tsx src/i18n/index.test.tsx
Tested: cd desktop && bun run lint
Tested: cd desktop && bun run build
Tested: bun run verify
Not-tested: Live H5 token generation against an enabled local H5 service; current local desktop state had H5 disabled.
This commit is contained in:
程序员阿江(Relakkes) 2026-05-12 18:52:14 +08:00
parent de5989549f
commit d41d5a87f0
5 changed files with 341 additions and 286 deletions

View File

@ -325,10 +325,16 @@ describe('Settings > General tab', () => {
})
})
it('renders the H5 section with access disabled by default', () => {
it('moves H5 access out of General into its own Settings tab', () => {
render(<Settings />)
fireEvent.click(screen.getByText('General'))
expect(screen.queryByRole('region', { name: 'H5 Access' })).not.toBeInTheDocument()
const generalTab = screen.getByText('General')
const h5Tab = screen.getByText('H5 Access')
expect((generalTab.compareDocumentPosition(h5Tab) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0).toBe(true)
fireEvent.click(h5Tab)
const section = screen.getByRole('region', { name: 'H5 Access' })
expect(within(section).getByLabelText('Enable H5 access')).not.toBeChecked()
@ -349,7 +355,7 @@ describe('Settings > General tab', () => {
})
render(<Settings />)
fireEvent.click(screen.getByText('General'))
fireEvent.click(screen.getByText('H5 Access'))
const section = screen.getByRole('region', { name: 'H5 Access' })
fireEvent.click(within(section).getByLabelText('Enable H5 access'))
@ -376,7 +382,7 @@ describe('Settings > General tab', () => {
})
render(<Settings />)
fireEvent.click(screen.getByText('General'))
fireEvent.click(screen.getByText('H5 Access'))
const section = screen.getByRole('region', { name: 'H5 Access' })
fireEvent.click(within(section).getByLabelText('Enable H5 access'))
@ -394,6 +400,32 @@ describe('Settings > General tab', () => {
)
})
it('guides enabled H5 users to generate a token before the QR code exists', async () => {
useSettingsStore.setState({
h5Access: {
enabled: true,
tokenPreview: 'h5oldtok',
allowedOrigins: [],
publicBaseUrl: 'http://192.168.0.102:3456',
},
})
render(<Settings />)
fireEvent.click(screen.getByText('H5 Access'))
const section = screen.getByRole('region', { name: 'H5 Access' })
expect(within(section).getByText('Generate a token to create the QR code.')).toBeInTheDocument()
expect(within(section).getByText('Click Generate token to create a QR link that can be scanned.')).toBeInTheDocument()
expect(within(section).queryByAltText('H5 access QR code')).not.toBeInTheDocument()
await act(async () => {
fireEvent.click(within(section).getByRole('button', { name: 'Generate token' }))
})
expect(useSettingsStore.getState().regenerateH5AccessToken).toHaveBeenCalledTimes(1)
expect(await within(section).findByAltText('H5 access QR code')).toBeInTheDocument()
})
it('shows the generated H5 token as a fallback when requested', async () => {
useSettingsStore.setState({
h5Access: {
@ -405,7 +437,7 @@ describe('Settings > General tab', () => {
})
render(<Settings />)
fireEvent.click(screen.getByText('General'))
fireEvent.click(screen.getByText('H5 Access'))
const section = screen.getByRole('region', { name: 'H5 Access' })
fireEvent.click(within(section).getByLabelText('Enable H5 access'))
@ -418,16 +450,6 @@ describe('Settings > General tab', () => {
expect(within(section).getByText('h5_default_generated_token')).toBeInTheDocument()
})
it('places H5 access after the common General settings sections', () => {
render(<Settings />)
fireEvent.click(screen.getByText('General'))
const webSearchTitle = screen.getByRole('heading', { name: 'WebSearch' })
const h5Title = screen.getByRole('heading', { name: 'H5 Access' })
expect((webSearchTitle.compareDocumentPosition(h5Title) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0).toBe(true)
})
it('copies the H5 URL when available', async () => {
useSettingsStore.setState({
h5Access: {
@ -439,7 +461,7 @@ describe('Settings > General tab', () => {
})
render(<Settings />)
fireEvent.click(screen.getByText('General'))
fireEvent.click(screen.getByText('H5 Access'))
const section = screen.getByRole('region', { name: 'H5 Access' })
await act(async () => {
@ -453,7 +475,7 @@ describe('Settings > General tab', () => {
useSettingsStore.setState({ h5AccessError: 'H5 unavailable' })
render(<Settings />)
fireEvent.click(screen.getByText('General'))
fireEvent.click(screen.getByText('H5 Access'))
const section = screen.getByRole('region', { name: 'H5 Access' })
expect(within(section).getByText('H5 unavailable')).toBeInTheDocument()
@ -470,7 +492,7 @@ describe('Settings > General tab', () => {
})
render(<Settings />)
fireEvent.click(screen.getByText('General'))
fireEvent.click(screen.getByText('H5 Access'))
const section = screen.getByRole('region', { name: 'H5 Access' })
fireEvent.change(within(section).getByLabelText('Public URL'), {

View File

@ -103,6 +103,7 @@ export const en = {
'settings.tab.permissions': 'Permissions',
'settings.tab.activity': 'Token usage',
'settings.tab.general': 'General',
'settings.tab.h5Access': 'H5 Access',
'settings.tab.terminal': 'Terminal',
'settings.tab.skills': 'Skills',
'settings.tab.mcp': 'MCP',
@ -716,6 +717,7 @@ export const en = {
'settings.general.h5AccessTokenPreview': 'Token preview',
'settings.general.h5AccessDisabledValue': 'Disabled',
'settings.general.h5AccessDisable': 'Disable',
'settings.general.h5AccessGenerateToken': 'Generate token',
'settings.general.h5AccessRegenerate': 'Regenerate token',
'settings.general.h5AccessGeneratedToken': 'Generated token',
'settings.general.h5AccessGeneratedTokenHint': 'Shown once. Copy it now and store it like a password.',
@ -735,7 +737,8 @@ export const en = {
'settings.general.h5AccessUrl': 'H5 URL',
'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.h5AccessQrRefreshHint': 'Click Generate token to create a QR link that can be scanned.',
'settings.general.h5AccessQrEmptyHint': 'Generate a token to create the QR code.',
'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?',

View File

@ -105,6 +105,7 @@ export const zh: Record<TranslationKey, string> = {
'settings.tab.permissions': '权限',
'settings.tab.activity': 'Token 用量',
'settings.tab.general': '通用',
'settings.tab.h5Access': 'H5 访问',
'settings.tab.terminal': '终端',
'settings.tab.skills': '技能',
'settings.tab.mcp': 'MCP',
@ -718,6 +719,7 @@ export const zh: Record<TranslationKey, string> = {
'settings.general.h5AccessTokenPreview': '令牌预览',
'settings.general.h5AccessDisabledValue': '未启用',
'settings.general.h5AccessDisable': '关闭',
'settings.general.h5AccessGenerateToken': '生成令牌',
'settings.general.h5AccessRegenerate': '重新生成令牌',
'settings.general.h5AccessGeneratedToken': '生成的令牌',
'settings.general.h5AccessGeneratedTokenHint': '仅显示一次,请立即复制并像密码一样保存。',
@ -737,7 +739,8 @@ export const zh: Record<TranslationKey, string> = {
'settings.general.h5AccessUrl': 'H5 链接',
'settings.general.h5AccessQrTitle': '手机连接',
'settings.general.h5AccessQrHint': '扫码会直接打开 H5并自动带上 token。',
'settings.general.h5AccessQrRefreshHint': '重新生成 token 后会创建可扫码的连接。',
'settings.general.h5AccessQrRefreshHint': '点击“生成令牌”后会创建可扫码的连接。',
'settings.general.h5AccessQrEmptyHint': '生成令牌后会在这里显示二维码。',
'settings.general.h5AccessQrAlt': 'H5 访问二维码',
'settings.general.h5AccessSafetyNote': '只在可信网络中启用。拿到二维码链接的人可以访问 H5 暴露的桌面能力。',
'settings.general.h5AccessConfirmTitle': '启用局域网 H5 访问?',

View File

@ -86,6 +86,7 @@ export function Settings() {
<TabButton icon="dns" label={t('settings.tab.providers')} active={activeTab === 'providers'} onClick={() => setActiveTab('providers')} />
<TabButton icon="shield" label={t('settings.tab.permissions')} active={activeTab === 'permissions'} onClick={() => setActiveTab('permissions')} />
<TabButton icon="tune" label={t('settings.tab.general')} active={activeTab === 'general'} onClick={() => setActiveTab('general')} />
<TabButton icon="qr_code_2" label={t('settings.tab.h5Access')} active={activeTab === 'h5Access'} onClick={() => setActiveTab('h5Access')} />
<TabButton icon="chat" label={t('settings.tab.adapters')} active={activeTab === 'adapters'} onClick={() => setActiveTab('adapters')} />
<TabButton icon="terminal" label={t('settings.tab.terminal')} active={activeTab === 'terminal'} onClick={() => setActiveTab('terminal')} />
<TabButton icon="dns" label={t('settings.tab.mcp')} active={activeTab === 'mcp'} onClick={() => setActiveTab('mcp')} />
@ -107,6 +108,7 @@ export function Settings() {
{activeTab === 'permissions' && <PermissionSettings />}
{activeTab === 'activity' && <ActivitySettings />}
{activeTab === 'general' && <GeneralSettings />}
{activeTab === 'h5Access' && <H5AccessSettings />}
{activeTab === 'adapters' && <AdapterSettings />}
{activeTab === 'terminal' && <TerminalSettings />}
{activeTab === 'mcp' && <McpSettings />}
@ -1378,63 +1380,19 @@ function GeneralSettings() {
setDesktopNotificationsEnabled,
webSearch,
setWebSearch,
h5Access,
h5AccessError,
enableH5Access,
disableH5Access,
regenerateH5AccessToken,
updateH5AccessSettings,
responseLanguage,
setResponseLanguage,
} = useSettingsStore()
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(() => {
setWebSearchDraft(webSearch)
}, [webSearch])
useEffect(() => {
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) => {
@ -1549,60 +1507,6 @@ function GeneralSettings() {
}
}
const runH5Action = async (action: () => Promise<void>) => {
setH5ActionRunning(true)
try {
await action()
} catch {
// The store owns H5-specific error state.
} finally {
setH5ActionRunning(false)
}
}
const handleH5SettingsSave = async () => {
await runH5Action(async () => {
await updateH5AccessSettings({
publicBaseUrl: h5PublicBaseUrlDraft.trim() || null,
})
})
}
const handleH5UrlCopy = async () => {
if (!h5AccessUrl) return
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 */}
@ -1870,204 +1774,326 @@ function GeneralSettings() {
</div>
</div>
<div className="mt-8">
<section aria-labelledby="general-h5-access-title" role="region">
<h2
id="general-h5-access-title"
className="text-base font-semibold text-[var(--color-text-primary)] mb-1"
>
{t('settings.general.h5AccessTitle')}
</h2>
<p className="text-sm text-[var(--color-text-tertiary)] mb-3">
{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="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>
)
}
<div className="mt-4 grid grid-cols-1 gap-3">
<Input
id="h5-access-public-url"
label={t('settings.general.h5AccessPublicUrl')}
value={h5PublicBaseUrlDraft}
placeholder={t('settings.general.h5AccessPublicUrlPlaceholder')}
onChange={(event) => setH5PublicBaseUrlDraft(event.target.value)}
// ─── H5 Access Settings ──────────────────────────────────────
function H5AccessSettings() {
const {
h5Access,
h5AccessError,
enableH5Access,
disableH5Access,
regenerateH5AccessToken,
updateH5AccessSettings,
} = useSettingsStore()
const t = useTranslation()
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 [h5ActionRunning, setH5ActionRunning] = useState(false)
const h5AccessUrl = h5Access.publicBaseUrl
const h5LaunchUrl = useMemo(
() => buildH5LaunchUrl(h5AccessUrl, h5GeneratedToken),
[h5AccessUrl, h5GeneratedToken],
)
const h5AccessDirty = h5PublicBaseUrlDraft.trim() !== (h5Access.publicBaseUrl ?? '')
useEffect(() => {
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])
const runH5Action = async (action: () => Promise<void>) => {
setH5ActionRunning(true)
try {
await action()
} catch {
// The store owns H5-specific error state.
} finally {
setH5ActionRunning(false)
}
}
const handleH5SettingsSave = async () => {
await runH5Action(async () => {
await updateH5AccessSettings({
publicBaseUrl: h5PublicBaseUrlDraft.trim() || null,
})
})
}
const handleH5UrlCopy = async () => {
if (!h5AccessUrl) return
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-3xl">
<section aria-labelledby="h5-access-title" role="region">
<div className="mb-5 flex items-start gap-3">
<div className="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-container-low)] text-[var(--color-brand)]">
<QrCode className="h-5 w-5" aria-hidden="true" />
</div>
<div className="min-w-0">
<h2
id="h5-access-title"
className="text-base font-semibold text-[var(--color-text-primary)] mb-1"
>
{t('settings.general.h5AccessTitle')}
</h2>
<p className="text-sm text-[var(--color-text-tertiary)]">
{t('settings.general.h5AccessDescription')}
</p>
</div>
</div>
<div className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-4 py-4">
<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()
}
}}
/>
<div className="flex items-center justify-between gap-3">
<p className="text-xs text-[var(--color-text-tertiary)]">
{t('settings.general.h5AccessOpenHint')}
</p>
<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')}
value={h5PublicBaseUrlDraft}
placeholder={t('settings.general.h5AccessPublicUrlPlaceholder')}
onChange={(event) => setH5PublicBaseUrlDraft(event.target.value)}
/>
<div className="flex items-center justify-between gap-3">
<p className="text-xs text-[var(--color-text-tertiary)]">
{t('settings.general.h5AccessOpenHint')}
</p>
<Button
size="sm"
variant="secondary"
onClick={() => void handleH5SettingsSave()}
disabled={!h5AccessDirty || h5ActionRunning}
aria-label={t('settings.general.h5AccessSave')}
>
{t('settings.general.h5AccessSave')}
</Button>
</div>
</div>
{h5AccessUrl && (
<div className="mt-4 border-t border-[var(--color-border)]/60 pt-4">
<div className="flex items-center gap-2">
<div className="flex-1 min-w-0">
<div className="text-xs uppercase tracking-[0.08em] text-[var(--color-text-tertiary)]">
{t('settings.general.h5AccessUrl')}
</div>
<div className="mt-1 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2 text-sm text-[var(--color-text-primary)] break-all">
{h5AccessUrl}
</div>
</div>
<Button
size="sm"
variant="secondary"
onClick={() => void handleH5SettingsSave()}
disabled={!h5AccessDirty || h5ActionRunning}
aria-label={t('settings.general.h5AccessSave')}
className="shrink-0"
icon={<Copy className="h-3.5 w-3.5" aria-hidden="true" />}
aria-label={t('settings.general.h5AccessCopyUrl')}
onClick={() => void handleH5UrlCopy()}
>
{t('settings.general.h5AccessSave')}
{t('settings.general.h5AccessCopy')}
</Button>
</div>
</div>
)}
{h5AccessUrl && (
<div className="mt-4 border-t border-[var(--color-border)]/60 pt-4">
<div className="flex items-center gap-2">
<div className="flex-1 min-w-0">
<div className="text-xs uppercase tracking-[0.08em] text-[var(--color-text-tertiary)]">
{t('settings.general.h5AccessUrl')}
</div>
<div className="mt-1 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2 text-sm text-[var(--color-text-primary)] break-all">
{h5AccessUrl}
</div>
</div>
<Button
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()}
>
{t('settings.general.h5AccessCopy')}
</Button>
</div>
</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"
/>
) : (
{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"
/>
) : (
<div className="flex flex-col items-center gap-3 px-4 text-center">
<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')}
<p className="text-xs leading-5 text-neutral-500">
{t('settings.general.h5AccessQrEmptyHint')}
</p>
</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 className="min-w-0 flex-1">
<div className="text-xs font-medium uppercase text-[var(--color-text-tertiary)]">
{t('settings.general.h5AccessQrTitle')}
</div>
<div className="flex shrink-0 flex-wrap justify-end gap-2">
<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={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)}
icon={<Copy className="h-3.5 w-3.5" aria-hidden="true" />}
disabled={!h5LaunchUrl || !h5GeneratedToken}
onClick={() => void handleH5LaunchUrlCopy()}
>
{h5TokenVisible ? t('settings.general.h5AccessHideToken') : t('settings.general.h5AccessShowToken')}
{t('settings.general.h5AccessCopyLaunchUrl')}
</Button>
<Button
size="sm"
variant="danger"
icon={<PowerOff className="h-3.5 w-3.5" aria-hidden="true" />}
variant={h5GeneratedToken ? 'secondary' : 'primary'}
icon={<RotateCw className="h-3.5 w-3.5" aria-hidden="true" />}
loading={h5ActionRunning}
onClick={() => void handleH5Disable()}
onClick={() => void handleH5Regenerate()}
>
{t('settings.general.h5AccessDisable')}
{h5GeneratedToken ? t('settings.general.h5AccessRegenerate') : t('settings.general.h5AccessGenerateToken')}
</Button>
</div>
</div>
</div>
)}
</div>
)}
<p className="mt-4 text-xs text-[var(--color-text-tertiary)] leading-5">
{t('settings.general.h5AccessSafetyNote')}
{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>
{h5AccessError && (
<p className="mt-2 text-xs text-[var(--color-error)]">
{h5AccessError}
</p>
{h5AccessError && (
<p className="mt-2 text-xs text-[var(--color-error)]">
{h5AccessError}
</p>
)}
</div>
</section>
</div>
)}
</div>
</section>
<ConfirmDialog
open={h5EnableConfirmOpen}

View File

@ -33,6 +33,7 @@ export type SettingsTab =
| 'permissions'
| 'activity'
| 'general'
| 'h5Access'
| 'adapters'
| 'terminal'
| 'mcp'