feat(ui): Add an automated Web Crypto environment check (#908)

* feat: add web crypto environment check
* fix: auto check env
* refactor:  WebCryptoEnvironmentCheck  swtich to map
This commit is contained in:
Ember
2025-11-12 10:22:55 +08:00
committed by GitHub
parent 7afe1f1bad
commit dbb05f7fde
5 changed files with 297 additions and 27 deletions

View File

@@ -18,6 +18,10 @@ import {
TwoStageKeyModal,
type TwoStageKeyModalResult,
} from './TwoStageKeyModal'
import {
WebCryptoEnvironmentCheck,
type WebCryptoCheckStatus,
} from './WebCryptoEnvironmentCheck'
import {
Bot,
Brain,
@@ -1772,6 +1776,8 @@ function ExchangeConfigModal({
} | null>(null)
const [loadingIP, setLoadingIP] = useState(false)
const [copiedIP, setCopiedIP] = useState(false)
const [webCryptoStatus, setWebCryptoStatus] =
useState<WebCryptoCheckStatus>('idle')
// 币安配置指南展开状态
const [showBinanceGuide, setShowBinanceGuide] = useState(false)
@@ -2008,34 +2014,51 @@ function ExchangeConfigModal({
style={{ maxHeight: 'calc(100vh - 16rem)' }}
>
{!editingExchangeId && (
<div>
<label
className="block text-sm font-semibold mb-2"
style={{ color: '#EAECEF' }}
>
{t('selectExchange', language)}
</label>
<select
value={selectedExchangeId}
onChange={(e) => setSelectedExchangeId(e.target.value)}
className="w-full px-3 py-2 rounded"
style={{
background: '#0B0E11',
border: '1px solid #2B3139',
color: '#EAECEF',
}}
required
>
<option value="">
{t('pleaseSelectExchange', language)}
</option>
{availableExchanges.map((exchange) => (
<option key={exchange.id} value={exchange.id}>
{getShortName(exchange.name)} (
{exchange.type.toUpperCase()})
<div className="space-y-3">
<div className="space-y-2">
<div
className="text-xs font-semibold uppercase tracking-wide"
style={{ color: '#F0B90B' }}
>
{t('environmentSteps.checkTitle', language)}
</div>
<WebCryptoEnvironmentCheck
language={language}
variant="card"
onStatusChange={setWebCryptoStatus}
/>
</div>
<div className="space-y-2">
<div
className="text-xs font-semibold uppercase tracking-wide"
style={{ color: '#F0B90B' }}
>
{t('environmentSteps.selectTitle', language)}
</div>
<select
value={selectedExchangeId}
onChange={(e) => setSelectedExchangeId(e.target.value)}
className="w-full px-3 py-2 rounded"
style={{
background: '#0B0E11',
border: '1px solid #2B3139',
color: '#EAECEF',
}}
aria-label={t('selectExchange', language)}
disabled={webCryptoStatus !== 'secure'}
required
>
<option value="">
{t('pleaseSelectExchange', language)}
</option>
))}
</select>
{availableExchanges.map((exchange) => (
<option key={exchange.id} value={exchange.id}>
{getShortName(exchange.name)} (
{exchange.type.toUpperCase()})
</option>
))}
</select>
</div>
</div>
)}

View File

@@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { t, type Language } from '../i18n/translations'
import { toast } from 'sonner'
import { WebCryptoEnvironmentCheck } from './WebCryptoEnvironmentCheck'
const DEFAULT_LENGTH = 64
@@ -197,6 +198,10 @@ export function TwoStageKeyModal({
</p>
</div>
<div className="mb-6">
<WebCryptoEnvironmentCheck language={language} variant="compact" />
</div>
{/* Stage 1 */}
{stage === 1 && (
<div className="space-y-4">

View File

@@ -0,0 +1,138 @@
import { useCallback, useEffect, useState, type ReactNode } from 'react'
import { Loader2, ShieldAlert, ShieldCheck } from 'lucide-react'
import { diagnoseWebCryptoEnvironment } from '../lib/crypto'
import { t, type Language } from '../i18n/translations'
export type WebCryptoCheckStatus =
| 'idle'
| 'checking'
| 'secure'
| 'insecure'
| 'unsupported'
interface WebCryptoEnvironmentCheckProps {
language: Language
variant?: 'card' | 'compact'
onStatusChange?: (status: WebCryptoCheckStatus) => void
}
export function WebCryptoEnvironmentCheck({
language,
variant = 'card',
onStatusChange,
}: WebCryptoEnvironmentCheckProps) {
const [status, setStatus] = useState<WebCryptoCheckStatus>('idle')
const [summary, setSummary] = useState<string | null>(null)
useEffect(() => {
onStatusChange?.(status)
}, [onStatusChange, status])
const runCheck = useCallback(() => {
setStatus('checking')
setSummary(null)
setTimeout(() => {
const result = diagnoseWebCryptoEnvironment()
setSummary(
t('environmentCheck.summary', language, {
origin: result.origin || 'N/A',
protocol: result.protocol || 'unknown',
})
)
if (!result.isBrowser || !result.hasSubtleCrypto) {
setStatus('unsupported')
return
}
if (!result.isSecureContext) {
setStatus('insecure')
return
}
setStatus('secure')
}, 0)
}, [language, t])
useEffect(() => {
runCheck()
}, [runCheck])
const isCompact = variant === 'compact'
const containerClass = isCompact
? 'p-3 rounded border border-gray-700 bg-gray-900 space-y-3'
: 'p-4 rounded border border-[#2B3139] bg-[#0B0E11] space-y-4'
const descriptionColor = isCompact ? '#CBD5F5' : '#A1AEC8'
const showInfo = status !== 'idle'
const statusRendererMap: Record<WebCryptoCheckStatus, () => ReactNode> = {
secure: () => (
<div className="flex items-start gap-2 text-green-400 text-xs">
<ShieldCheck className="w-4 h-4 flex-shrink-0" />
<div>
<div className="font-semibold">
{t('environmentCheck.secureTitle', language)}
</div>
<div>{t('environmentCheck.secureDesc', language)}</div>
</div>
</div>
),
insecure: () => (
<div className="text-xs" style={{ color: '#F59E0B' }}>
<div className="flex items-start gap-2 mb-1">
<ShieldAlert className="w-4 h-4 flex-shrink-0" />
<div className="font-semibold">
{t('environmentCheck.insecureTitle', language)}
</div>
</div>
<div>{t('environmentCheck.insecureDesc', language)}</div>
<div className="mt-2 font-semibold">
{t('environmentCheck.tipsTitle', language)}
</div>
<ul className="list-disc pl-5 space-y-1 mt-1">
<li>{t('environmentCheck.tipHTTPS', language)}</li>
<li>{t('environmentCheck.tipLocalhost', language)}</li>
<li>{t('environmentCheck.tipIframe', language)}</li>
</ul>
</div>
),
unsupported: () => (
<div className="text-xs" style={{ color: '#F87171' }}>
<div className="flex items-start gap-2 mb-1">
<ShieldAlert className="w-4 h-4 flex-shrink-0" />
<div className="font-semibold">
{t('environmentCheck.unsupportedTitle', language)}
</div>
</div>
<div>{t('environmentCheck.unsupportedDesc', language)}</div>
</div>
),
checking: () => (
<div
className="flex items-center gap-2 text-xs"
style={{ color: '#EAECEF' }}
>
<Loader2 className="w-4 h-4 animate-spin" />
<span>{t('environmentCheck.checking', language)}</span>
</div>
),
idle: () => null,
}
const renderStatus = () => statusRendererMap[status]()
return (
<div className={containerClass}>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
{showInfo && (
<div className="text-xs" style={{ color: descriptionColor }}>
{summary ?? t('environmentCheck.description', language)}
</div>
)}
</div>
{showInfo && <div className="min-h-[1.5rem]">{renderStatus()}</div>}
</div>
)
}