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

View File

@@ -786,6 +786,36 @@ export const translations = {
faqGetHelpAnswer:
'Check GitHub Discussions, join our Telegram Community, or open an issue on GitHub.',
// Web Crypto Environment Check
environmentCheck: {
button: 'Check Secure Environment',
checking: 'Checking...',
description:
'Automatically verifying whether this browser context allows Web Crypto before entering sensitive keys.',
secureTitle: 'Secure context detected',
secureDesc:
'Web Crypto API is available. You can continue entering secrets with encryption enabled.',
insecureTitle: 'Insecure context detected',
insecureDesc:
'This page is not running over HTTPS or a trusted localhost origin, so browsers block Web Crypto calls.',
tipsTitle: 'How to fix:',
tipHTTPS:
'Serve the dashboard over HTTPS with a valid certificate (IP origins also need TLS).',
tipLocalhost:
'During development, open the app via http://localhost or 127.0.0.1.',
tipIframe:
'Avoid embedding the app in insecure HTTP iframes or reverse proxies that strip HTTPS.',
unsupportedTitle: 'Browser does not expose Web Crypto',
unsupportedDesc:
'Open NOFX over HTTPS (or http://localhost during development) and avoid insecure iframes/reverse proxies so the browser can enable Web Crypto.',
summary: 'Current origin: {origin} • Protocol: {protocol}',
},
environmentSteps: {
checkTitle: '1. Environment check',
selectTitle: '2. Select exchange',
},
// Two-Stage Key Modal
twoStageKey: {
title: 'Two-Stage Private Key Input',
@@ -1550,6 +1580,33 @@ export const translations = {
faqGetHelpAnswer:
'查看 GitHub Discussions、加入 Telegram 社区或在 GitHub 上提出 issue。',
// Web Crypto Environment Check
environmentCheck: {
button: '一键检测环境',
checking: '正在检测...',
description: '系统将自动检测当前浏览器是否允许使用 Web Crypto。',
secureTitle: '环境安全,已启用 Web Crypto',
secureDesc: '页面处于安全上下文,可继续输入敏感信息并使用加密传输。',
insecureTitle: '检测到非安全环境',
insecureDesc:
'当前访问未通过 HTTPS 或可信 localhost浏览器会阻止 Web Crypto 调用。',
tipsTitle: '修改建议:',
tipHTTPS:
'通过 HTTPS 访问(即使是 IP 也需证书),或部署到支持 TLS 的域名。',
tipLocalhost: '开发阶段请使用 http://localhost 或 127.0.0.1。',
tipIframe:
'避免把应用嵌入在不安全的 HTTP iframe 或会降级协议的反向代理中。',
unsupportedTitle: '浏览器未提供 Web Crypto',
unsupportedDesc:
'请通过 HTTPS 或本机 localhost 访问 NOFX并避免嵌入不安全 iframe/反向代理,以符合浏览器的 Web Crypto 规则。',
summary: '当前来源:{origin} · 协议:{protocol}',
},
environmentSteps: {
checkTitle: '1. 环境检测',
selectTitle: '2. 选择交易所',
},
// Two-Stage Key Modal
twoStageKey: {
title: '两阶段私钥输入',

View File

@@ -7,6 +7,16 @@ export interface EncryptedPayload {
ts?: number // 可选unix 秒,用于重放保护
}
export interface WebCryptoEnvironmentInfo {
isBrowser: boolean
isSecureContext: boolean
hasSubtleCrypto: boolean
origin?: string
protocol?: string
hostname?: string
isLocalhost?: boolean
}
export class CryptoService {
private static publicKey: CryptoKey | null = null
private static publicKeyPEM: string | null = null
@@ -186,3 +196,40 @@ export function validatePrivateKeyFormat(
}
return /^[0-9a-fA-F]+$/.test(normalized)
}
export function diagnoseWebCryptoEnvironment(): WebCryptoEnvironmentInfo {
if (typeof window === 'undefined') {
return {
isBrowser: false,
isSecureContext: false,
hasSubtleCrypto: false,
}
}
const { location } = window
const hostname = location?.hostname
const protocol = location?.protocol
const origin = location?.origin
const isLocalhost = hostname
? ['localhost', '127.0.0.1', '::1'].includes(hostname)
: false
const secureContext =
typeof window.isSecureContext === 'boolean'
? window.isSecureContext
: protocol === 'https:' || (protocol === 'http:' && isLocalhost)
const hasSubtleCrypto =
typeof window.crypto !== 'undefined' &&
typeof window.crypto.subtle !== 'undefined'
return {
isBrowser: true,
isSecureContext: secureContext,
hasSubtleCrypto,
origin: origin || undefined,
protocol: protocol || undefined,
hostname,
isLocalhost,
}
}