mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-14 00:07:01 +08:00
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:
@@ -18,6 +18,10 @@ import {
|
|||||||
TwoStageKeyModal,
|
TwoStageKeyModal,
|
||||||
type TwoStageKeyModalResult,
|
type TwoStageKeyModalResult,
|
||||||
} from './TwoStageKeyModal'
|
} from './TwoStageKeyModal'
|
||||||
|
import {
|
||||||
|
WebCryptoEnvironmentCheck,
|
||||||
|
type WebCryptoCheckStatus,
|
||||||
|
} from './WebCryptoEnvironmentCheck'
|
||||||
import {
|
import {
|
||||||
Bot,
|
Bot,
|
||||||
Brain,
|
Brain,
|
||||||
@@ -1772,6 +1776,8 @@ function ExchangeConfigModal({
|
|||||||
} | null>(null)
|
} | null>(null)
|
||||||
const [loadingIP, setLoadingIP] = useState(false)
|
const [loadingIP, setLoadingIP] = useState(false)
|
||||||
const [copiedIP, setCopiedIP] = useState(false)
|
const [copiedIP, setCopiedIP] = useState(false)
|
||||||
|
const [webCryptoStatus, setWebCryptoStatus] =
|
||||||
|
useState<WebCryptoCheckStatus>('idle')
|
||||||
|
|
||||||
// 币安配置指南展开状态
|
// 币安配置指南展开状态
|
||||||
const [showBinanceGuide, setShowBinanceGuide] = useState(false)
|
const [showBinanceGuide, setShowBinanceGuide] = useState(false)
|
||||||
@@ -2008,34 +2014,51 @@ function ExchangeConfigModal({
|
|||||||
style={{ maxHeight: 'calc(100vh - 16rem)' }}
|
style={{ maxHeight: 'calc(100vh - 16rem)' }}
|
||||||
>
|
>
|
||||||
{!editingExchangeId && (
|
{!editingExchangeId && (
|
||||||
<div>
|
<div className="space-y-3">
|
||||||
<label
|
<div className="space-y-2">
|
||||||
className="block text-sm font-semibold mb-2"
|
<div
|
||||||
style={{ color: '#EAECEF' }}
|
className="text-xs font-semibold uppercase tracking-wide"
|
||||||
>
|
style={{ color: '#F0B90B' }}
|
||||||
{t('selectExchange', language)}
|
>
|
||||||
</label>
|
{t('environmentSteps.checkTitle', language)}
|
||||||
<select
|
</div>
|
||||||
value={selectedExchangeId}
|
<WebCryptoEnvironmentCheck
|
||||||
onChange={(e) => setSelectedExchangeId(e.target.value)}
|
language={language}
|
||||||
className="w-full px-3 py-2 rounded"
|
variant="card"
|
||||||
style={{
|
onStatusChange={setWebCryptoStatus}
|
||||||
background: '#0B0E11',
|
/>
|
||||||
border: '1px solid #2B3139',
|
</div>
|
||||||
color: '#EAECEF',
|
<div className="space-y-2">
|
||||||
}}
|
<div
|
||||||
required
|
className="text-xs font-semibold uppercase tracking-wide"
|
||||||
>
|
style={{ color: '#F0B90B' }}
|
||||||
<option value="">
|
>
|
||||||
{t('pleaseSelectExchange', language)}
|
{t('environmentSteps.selectTitle', language)}
|
||||||
</option>
|
</div>
|
||||||
{availableExchanges.map((exchange) => (
|
<select
|
||||||
<option key={exchange.id} value={exchange.id}>
|
value={selectedExchangeId}
|
||||||
{getShortName(exchange.name)} (
|
onChange={(e) => setSelectedExchangeId(e.target.value)}
|
||||||
{exchange.type.toUpperCase()})
|
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>
|
</option>
|
||||||
))}
|
{availableExchanges.map((exchange) => (
|
||||||
</select>
|
<option key={exchange.id} value={exchange.id}>
|
||||||
|
{getShortName(exchange.name)} (
|
||||||
|
{exchange.type.toUpperCase()})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'
|
|||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
import { t, type Language } from '../i18n/translations'
|
import { t, type Language } from '../i18n/translations'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
import { WebCryptoEnvironmentCheck } from './WebCryptoEnvironmentCheck'
|
||||||
|
|
||||||
const DEFAULT_LENGTH = 64
|
const DEFAULT_LENGTH = 64
|
||||||
|
|
||||||
@@ -197,6 +198,10 @@ export function TwoStageKeyModal({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-6">
|
||||||
|
<WebCryptoEnvironmentCheck language={language} variant="compact" />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Stage 1 */}
|
{/* Stage 1 */}
|
||||||
{stage === 1 && (
|
{stage === 1 && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|||||||
138
web/src/components/WebCryptoEnvironmentCheck.tsx
Normal file
138
web/src/components/WebCryptoEnvironmentCheck.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -786,6 +786,36 @@ export const translations = {
|
|||||||
faqGetHelpAnswer:
|
faqGetHelpAnswer:
|
||||||
'Check GitHub Discussions, join our Telegram Community, or open an issue on GitHub.',
|
'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
|
// Two-Stage Key Modal
|
||||||
twoStageKey: {
|
twoStageKey: {
|
||||||
title: 'Two-Stage Private Key Input',
|
title: 'Two-Stage Private Key Input',
|
||||||
@@ -1550,6 +1580,33 @@ export const translations = {
|
|||||||
faqGetHelpAnswer:
|
faqGetHelpAnswer:
|
||||||
'查看 GitHub Discussions、加入 Telegram 社区或在 GitHub 上提出 issue。',
|
'查看 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
|
// Two-Stage Key Modal
|
||||||
twoStageKey: {
|
twoStageKey: {
|
||||||
title: '两阶段私钥输入',
|
title: '两阶段私钥输入',
|
||||||
|
|||||||
@@ -7,6 +7,16 @@ export interface EncryptedPayload {
|
|||||||
ts?: number // 可选:unix 秒,用于重放保护
|
ts?: number // 可选:unix 秒,用于重放保护
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WebCryptoEnvironmentInfo {
|
||||||
|
isBrowser: boolean
|
||||||
|
isSecureContext: boolean
|
||||||
|
hasSubtleCrypto: boolean
|
||||||
|
origin?: string
|
||||||
|
protocol?: string
|
||||||
|
hostname?: string
|
||||||
|
isLocalhost?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export class CryptoService {
|
export class CryptoService {
|
||||||
private static publicKey: CryptoKey | null = null
|
private static publicKey: CryptoKey | null = null
|
||||||
private static publicKeyPEM: string | null = null
|
private static publicKeyPEM: string | null = null
|
||||||
@@ -186,3 +196,40 @@ export function validatePrivateKeyFormat(
|
|||||||
}
|
}
|
||||||
return /^[0-9a-fA-F]+$/.test(normalized)
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user