mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
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:
parent
de5989549f
commit
d41d5a87f0
@ -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'), {
|
||||
|
||||
@ -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?',
|
||||
|
||||
@ -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 访问?',
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -33,6 +33,7 @@ export type SettingsTab =
|
||||
| 'permissions'
|
||||
| 'activity'
|
||||
| 'general'
|
||||
| 'h5Access'
|
||||
| 'adapters'
|
||||
| 'terminal'
|
||||
| 'mcp'
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user