mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-17 03:27:00 +08:00
feat: 支持自动获取并安全保存数据库密钥
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
import { app, safeStorage } from 'electron'
|
||||
import fs from 'fs-extra'
|
||||
import path from 'path'
|
||||
|
||||
export interface StoredKeyResult {
|
||||
success: boolean
|
||||
key?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
const normalizeDatabaseKey = (value: string): string => value.trim().replace(/^0x/i, '')
|
||||
|
||||
export const isValidDatabaseKey = (value: string): boolean =>
|
||||
/^[0-9a-f]{64}$/i.test(normalizeDatabaseKey(value))
|
||||
|
||||
export class DatabaseKeyStore {
|
||||
private get filePath(): string {
|
||||
return path.join(app.getPath('userData'), 'wechat-db-key.bin')
|
||||
}
|
||||
|
||||
async load(): Promise<StoredKeyResult> {
|
||||
try {
|
||||
if (!(await fs.pathExists(this.filePath))) return { success: true }
|
||||
if (!safeStorage.isEncryptionAvailable()) {
|
||||
return { success: false, error: '系统安全存储不可用' }
|
||||
}
|
||||
const encrypted = await fs.readFile(this.filePath)
|
||||
const key = normalizeDatabaseKey(safeStorage.decryptString(encrypted))
|
||||
if (!isValidDatabaseKey(key)) return { success: false, error: '已保存的密钥格式无效' }
|
||||
return { success: true, key }
|
||||
} catch (error) {
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
}
|
||||
|
||||
async save(rawKey: string): Promise<StoredKeyResult> {
|
||||
const key = normalizeDatabaseKey(rawKey)
|
||||
if (!isValidDatabaseKey(key)) {
|
||||
return { success: false, error: '密钥必须是 64 位十六进制字符' }
|
||||
}
|
||||
if (!safeStorage.isEncryptionAvailable()) {
|
||||
return { success: false, error: '系统安全存储不可用' }
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.ensureDir(path.dirname(this.filePath))
|
||||
await fs.writeFile(this.filePath, safeStorage.encryptString(key), { mode: 0o600 })
|
||||
await fs.chmod(this.filePath, 0o600)
|
||||
return { success: true, key }
|
||||
} catch (error) {
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
}
|
||||
|
||||
async clear(): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
await fs.remove(this.filePath)
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,11 +13,15 @@ import {
|
||||
import { ImageDecryptService } from './image-decrypt-service'
|
||||
import { exportGroupReport } from './group-report-service'
|
||||
import { GroupReportExportRequest } from '../shared/group-report'
|
||||
import { DatabaseKeyStore } from './database-key-store'
|
||||
import { KeyServiceMac } from './key-service-mac'
|
||||
|
||||
let wechatDb: WechatDb | null = null
|
||||
let voiceService: VoiceService | null = null
|
||||
let imageDecryptService: ImageDecryptService | null = null
|
||||
let stickerService: StickerService | null = null
|
||||
const databaseKeyStore = new DatabaseKeyStore()
|
||||
const keyServiceMac = new KeyServiceMac()
|
||||
const BUILD_MARK = 'wechat4-open-account-continues-after-init-1000'
|
||||
|
||||
// WechatExplorer's WCDB native library runs InitProtection before wcdb_init.
|
||||
@@ -119,6 +123,29 @@ app.whenReady().then(() => {
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('key:getSavedDbKey', async () => databaseKeyStore.load())
|
||||
|
||||
ipcMain.handle('key:pasteAndSaveDbKey', async () => {
|
||||
const clipboardKey = clipboard.readText().trim()
|
||||
return databaseKeyStore.save(clipboardKey)
|
||||
})
|
||||
|
||||
ipcMain.handle('key:clearSavedDbKey', async () => databaseKeyStore.clear())
|
||||
|
||||
ipcMain.handle('key:autoGetDbKey', async (event) => {
|
||||
const result = await keyServiceMac.autoGetDbKey((message) => {
|
||||
if (!event.sender.isDestroyed()) event.sender.send('key:dbKeyStatus', { message })
|
||||
})
|
||||
if (!result.success || !result.key) return result
|
||||
|
||||
const saved = await databaseKeyStore.save(result.key)
|
||||
return {
|
||||
...result,
|
||||
saved: saved.success,
|
||||
warning: saved.success ? undefined : saved.error
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('db:getContacts', (_, filter?: string) => {
|
||||
if (!wechatDb) return []
|
||||
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import { app } from 'electron'
|
||||
import { execFile } from 'child_process'
|
||||
import fs from 'fs-extra'
|
||||
import path from 'path'
|
||||
import { promisify } from 'util'
|
||||
import { isValidDatabaseKey } from './database-key-store'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
export interface DatabaseKeyResult {
|
||||
success: boolean
|
||||
key?: string
|
||||
error?: string
|
||||
code?: string
|
||||
}
|
||||
|
||||
export class KeyServiceMac {
|
||||
private getHelperPath(): string {
|
||||
const candidates = app.isPackaged
|
||||
? [
|
||||
path.join(process.resourcesPath, 'resources', 'xkey_helper'),
|
||||
path.join(process.resourcesPath, 'xkey_helper')
|
||||
]
|
||||
: [
|
||||
path.join(process.cwd(), 'resources', 'xkey_helper'),
|
||||
path.join(app.getAppPath(), 'resources', 'xkey_helper')
|
||||
]
|
||||
const helperPath = candidates.find((candidate) => fs.existsSync(candidate))
|
||||
if (!helperPath) throw new Error('找不到 xkey_helper')
|
||||
return helperPath
|
||||
}
|
||||
|
||||
private async isSipEnabled(): Promise<boolean> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync('/usr/bin/csrutil', ['status'])
|
||||
return stdout.toLowerCase().includes('enabled')
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private async getWeChatPid(): Promise<number> {
|
||||
const commands: [string, string[]][] = [
|
||||
['/usr/bin/pgrep', ['-x', 'WeChat']],
|
||||
['/usr/bin/pgrep', ['-f', 'WeChat.app/Contents/MacOS/WeChat']]
|
||||
]
|
||||
for (const [command, args] of commands) {
|
||||
try {
|
||||
const { stdout } = await execFileAsync(command, args)
|
||||
const pids = stdout
|
||||
.split(/\r?\n/)
|
||||
.map((value) => Number.parseInt(value.trim(), 10))
|
||||
.filter((value) => Number.isFinite(value) && value > 0)
|
||||
if (pids.length) return Math.max(...pids)
|
||||
} catch {
|
||||
// Try the next process lookup strategy.
|
||||
}
|
||||
}
|
||||
throw new Error('未找到微信主进程,请先启动并登录微信')
|
||||
}
|
||||
|
||||
private parseHelperOutput(output: string): DatabaseKeyResult {
|
||||
const payloads: Record<string, unknown>[] = []
|
||||
for (const match of output.matchAll(/\{[^{}]*\}/g)) {
|
||||
try {
|
||||
payloads.push(JSON.parse(match[0]) as Record<string, unknown>)
|
||||
} catch {
|
||||
// Ignore helper progress that is not JSON.
|
||||
}
|
||||
}
|
||||
const payload = payloads.find((item) => item.success === true && typeof item.key === 'string')
|
||||
const rawKey = typeof payload?.key === 'string' ? payload.key.trim().replace(/^0x/i, '') : ''
|
||||
if (!isValidDatabaseKey(rawKey)) {
|
||||
const errorPayload = payloads.find((item) => typeof item.result === 'string')
|
||||
const rawError = typeof errorPayload?.result === 'string' ? errorPayload.result.trim() : ''
|
||||
const parsedError = rawError.match(/^ERROR:([^:]+):?(.*)$/i)
|
||||
const code = parsedError?.[1]?.toUpperCase()
|
||||
const detail = parsedError?.[2]?.trim() || ''
|
||||
if (code === 'SCAN_FAILED' && detail.toLowerCase().includes('sink pattern not found')) {
|
||||
return {
|
||||
success: false,
|
||||
code,
|
||||
error:
|
||||
'内存扫描失败:未匹配到目标函数特征(Sink pattern not found),当前微信版本可能暂未适配。\n' +
|
||||
'建议步骤:降级微信到 4.1.7 -> 重启电脑(冷启动) -> 自动获取密钥 -> 成功后再升级微信。\n' +
|
||||
'请不要连续重试,以免触发微信安全模式或系统内存保护。'
|
||||
}
|
||||
}
|
||||
if (code === 'SCAN_FAILED') {
|
||||
return {
|
||||
success: false,
|
||||
code,
|
||||
error: `内存扫描失败:${detail || '未匹配到可用特征,当前微信版本可能暂未适配。'}`
|
||||
}
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
code,
|
||||
error: rawError || '密钥工具未返回有效的 64 位密钥'
|
||||
}
|
||||
}
|
||||
return { success: true, key: rawKey }
|
||||
}
|
||||
|
||||
async autoGetDbKey(
|
||||
onStatus?: (message: string) => void,
|
||||
timeoutMs = 60_000
|
||||
): Promise<DatabaseKeyResult> {
|
||||
if (process.platform !== 'darwin') {
|
||||
return { success: false, error: '自动获取密钥目前仅支持 macOS' }
|
||||
}
|
||||
if (await this.isSipEnabled()) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'macOS 系统完整性保护(SIP)已开启,自动获取不可用,请使用手动粘贴。'
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
onStatus?.('正在查找微信进程...')
|
||||
const pid = await this.getWeChatPid()
|
||||
const helperPath = this.getHelperPath()
|
||||
const waitMs = Math.max(30_000, timeoutMs)
|
||||
const timeoutSeconds = Math.ceil(waitMs / 1000) + 30
|
||||
onStatus?.('正在请求管理员授权...')
|
||||
const scriptLines = [
|
||||
`set helperPath to ${JSON.stringify(helperPath)}`,
|
||||
`set cmd to quoted form of helperPath & " ${pid} ${waitMs}"`,
|
||||
`set timeoutSec to ${timeoutSeconds}`,
|
||||
'try',
|
||||
'with timeout of timeoutSec seconds',
|
||||
'set outText to do shell script cmd with administrator privileges',
|
||||
'end timeout',
|
||||
'return "OK::" & outText',
|
||||
'on error errMsg number errNum',
|
||||
'return "ERR::" & errNum & "::" & errMsg',
|
||||
'end try'
|
||||
]
|
||||
onStatus?.('授权后请保持微信已登录并活动...')
|
||||
const { stdout } = await execFileAsync(
|
||||
'/usr/bin/osascript',
|
||||
scriptLines.flatMap((line) => ['-e', line]),
|
||||
{ timeout: waitMs + 20_000 }
|
||||
)
|
||||
const output = String(stdout).trim()
|
||||
if (output.startsWith('ERR::-128')) return { success: false, error: '已取消管理员授权' }
|
||||
if (output.startsWith('ERR::')) {
|
||||
return {
|
||||
success: false,
|
||||
error: output.split('::').slice(2).join('::') || '密钥工具执行失败'
|
||||
}
|
||||
}
|
||||
const result = this.parseHelperOutput(output.startsWith('OK::') ? output.slice(4) : output)
|
||||
onStatus?.(result.success ? '密钥获取成功' : '密钥获取失败')
|
||||
return result
|
||||
} catch (error) {
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+12
@@ -61,6 +61,18 @@ declare global {
|
||||
) => Promise<{ success: boolean; data?: string; error?: string }>
|
||||
exportGroupReport: (request: GroupReportExportRequest) => Promise<GroupReportExportResult>
|
||||
revealGroupReport: (filePath: string) => Promise<{ success: boolean; error?: string }>
|
||||
getSavedDbKey: () => Promise<{ success: boolean; key?: string; error?: string }>
|
||||
autoGetDbKey: () => Promise<{
|
||||
success: boolean
|
||||
key?: string
|
||||
error?: string
|
||||
code?: string
|
||||
saved?: boolean
|
||||
warning?: string
|
||||
}>
|
||||
pasteAndSaveDbKey: () => Promise<{ success: boolean; key?: string; error?: string }>
|
||||
clearSavedDbKey: () => Promise<{ success: boolean; error?: string }>
|
||||
onDbKeyStatus: (callback: (payload: { message: string }) => void) => () => void
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+11
-1
@@ -23,7 +23,17 @@ const api = {
|
||||
getSticker: (cdnUrl?: string, md5?: string) => ipcRenderer.invoke('db:getSticker', cdnUrl, md5),
|
||||
exportGroupReport: (request: GroupReportExportRequest) =>
|
||||
ipcRenderer.invoke('report:export', request),
|
||||
revealGroupReport: (filePath: string) => ipcRenderer.invoke('report:reveal', filePath)
|
||||
revealGroupReport: (filePath: string) => ipcRenderer.invoke('report:reveal', filePath),
|
||||
getSavedDbKey: () => ipcRenderer.invoke('key:getSavedDbKey'),
|
||||
autoGetDbKey: () => ipcRenderer.invoke('key:autoGetDbKey'),
|
||||
pasteAndSaveDbKey: () => ipcRenderer.invoke('key:pasteAndSaveDbKey'),
|
||||
clearSavedDbKey: () => ipcRenderer.invoke('key:clearSavedDbKey'),
|
||||
onDbKeyStatus: (callback: (payload: { message: string }) => void) => {
|
||||
const listener = (_event: Electron.IpcRendererEvent, payload: { message: string }): void =>
|
||||
callback(payload)
|
||||
ipcRenderer.on('key:dbKeyStatus', listener)
|
||||
return () => ipcRenderer.removeListener('key:dbKeyStatus', listener)
|
||||
}
|
||||
}
|
||||
|
||||
if (process.contextIsolated) {
|
||||
|
||||
+120
-7
@@ -3,6 +3,8 @@ import { Sidebar } from './components/Sidebar'
|
||||
import ChatWindow from './components/ChatWindow'
|
||||
import { Contact, Message } from '../../shared/types'
|
||||
|
||||
const MAC_KEY_FAQ_URL = 'https://github.com/hicccc77/WeFlow/blob/main/docs/MAC-KEY-FAQ.md'
|
||||
|
||||
function App(): React.ReactElement {
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false)
|
||||
const [dbKey, setDbKey] = useState(import.meta.env.VITE_DB_KEY || '')
|
||||
@@ -12,6 +14,32 @@ function App(): React.ReactElement {
|
||||
const [filteredContacts, setFilteredContacts] = useState<Contact[]>([])
|
||||
const [dateRange, setDateRange] = useState('today') // 默认为今天
|
||||
const [contentFilter, setContentFilter] = useState('')
|
||||
const [isFetchingDbKey, setIsFetchingDbKey] = useState(false)
|
||||
const [dbKeyStatus, setDbKeyStatus] = useState('')
|
||||
const [dbKeyStatusKind, setDbKeyStatusKind] = useState<'normal' | 'success' | 'error'>('normal')
|
||||
const [showDbKey, setShowDbKey] = useState(false)
|
||||
const [showMacKeyFaq, setShowMacKeyFaq] = useState(false)
|
||||
|
||||
React.useEffect(() => {
|
||||
let active = true
|
||||
void window.api.getSavedDbKey().then((result) => {
|
||||
if (!active) return
|
||||
if (result.success && result.key) {
|
||||
setDbKey(result.key)
|
||||
setDbKeyStatus('已加载安全保存的密钥')
|
||||
setDbKeyStatusKind('success')
|
||||
}
|
||||
})
|
||||
const unsubscribe = window.api.onDbKeyStatus(({ message }) => {
|
||||
if (!active) return
|
||||
setDbKeyStatus(message)
|
||||
setDbKeyStatusKind('normal')
|
||||
})
|
||||
return () => {
|
||||
active = false
|
||||
unsubscribe()
|
||||
}
|
||||
}, [])
|
||||
|
||||
// useEffect(() => {
|
||||
// if (import.meta.env.VITE_DB_KEY) {
|
||||
@@ -38,6 +66,55 @@ function App(): React.ReactElement {
|
||||
}
|
||||
}
|
||||
|
||||
const handleAutoGetDbKey = async (): Promise<void> => {
|
||||
if (isFetchingDbKey) return
|
||||
setIsFetchingDbKey(true)
|
||||
setDbKeyStatus('正在准备获取密钥...')
|
||||
setDbKeyStatusKind('normal')
|
||||
setShowMacKeyFaq(false)
|
||||
try {
|
||||
const result = await window.api.autoGetDbKey()
|
||||
if (!result.success || !result.key) {
|
||||
setShowMacKeyFaq(result.code === 'SCAN_FAILED')
|
||||
throw new Error(result.error || '获取密钥失败')
|
||||
}
|
||||
setDbKey(result.key)
|
||||
setDbKeyStatus(result.saved ? '密钥已获取并安全保存' : result.warning || '密钥已获取')
|
||||
setDbKeyStatusKind(result.saved ? 'success' : 'normal')
|
||||
} catch (error) {
|
||||
setDbKeyStatus(error instanceof Error ? error.message : String(error))
|
||||
setDbKeyStatusKind('error')
|
||||
} finally {
|
||||
setIsFetchingDbKey(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePasteAndSaveDbKey = async (): Promise<void> => {
|
||||
setShowMacKeyFaq(false)
|
||||
const result = await window.api.pasteAndSaveDbKey()
|
||||
if (result.success && result.key) {
|
||||
setDbKey(result.key)
|
||||
setDbKeyStatus('已从剪贴板粘贴并安全保存')
|
||||
setDbKeyStatusKind('success')
|
||||
} else {
|
||||
setDbKeyStatus(result.error || '粘贴并保存失败')
|
||||
setDbKeyStatusKind('error')
|
||||
}
|
||||
}
|
||||
|
||||
const handleClearSavedDbKey = async (): Promise<void> => {
|
||||
setShowMacKeyFaq(false)
|
||||
const result = await window.api.clearSavedDbKey()
|
||||
if (!result.success) {
|
||||
setDbKeyStatus(result.error || '清除密钥失败')
|
||||
setDbKeyStatusKind('error')
|
||||
return
|
||||
}
|
||||
setDbKey('')
|
||||
setDbKeyStatus('已清除保存的密钥')
|
||||
setDbKeyStatusKind('normal')
|
||||
}
|
||||
|
||||
const loadContacts = async (): Promise<void> => {
|
||||
const list = await window.api.getContacts()
|
||||
setContacts(list)
|
||||
@@ -146,16 +223,52 @@ function App(): React.ReactElement {
|
||||
<div className="login-modal">
|
||||
<div className="login-box">
|
||||
<h2>Enter WeChat DB Key</h2>
|
||||
<input
|
||||
type="password"
|
||||
className="login-input"
|
||||
value={dbKey}
|
||||
onChange={(e) => setDbKey(e.target.value)}
|
||||
placeholder="Key (e.g. 0x...)"
|
||||
/>
|
||||
<div className="login-input-wrapper">
|
||||
<input
|
||||
type={showDbKey ? 'text' : 'password'}
|
||||
className="login-input"
|
||||
value={dbKey}
|
||||
onChange={(e) => setDbKey(e.target.value)}
|
||||
placeholder="Key (e.g. 0x...)"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="login-input-toggle"
|
||||
onClick={() => setShowDbKey(!showDbKey)}
|
||||
title={showDbKey ? '隐藏密钥' : '显示密钥'}
|
||||
>
|
||||
{showDbKey ? '👁️' : '👁️🗨️'}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
className="login-btn login-btn-secondary"
|
||||
onClick={handleAutoGetDbKey}
|
||||
disabled={isFetchingDbKey}
|
||||
>
|
||||
{isFetchingDbKey ? '正在获取...' : '自动获取密钥'}
|
||||
</button>
|
||||
<button className="login-btn login-btn-secondary" onClick={handlePasteAndSaveDbKey}>
|
||||
粘贴并安全保存
|
||||
</button>
|
||||
<button className="login-btn" onClick={() => handleLogin()}>
|
||||
Connect
|
||||
</button>
|
||||
<button className="login-clear-btn" onClick={handleClearSavedDbKey}>
|
||||
清除已保存密钥
|
||||
</button>
|
||||
{dbKeyStatus && (
|
||||
<div className={`login-key-status ${dbKeyStatusKind}`}>{dbKeyStatus}</div>
|
||||
)}
|
||||
{showMacKeyFaq && (
|
||||
<a
|
||||
className="login-key-help-link"
|
||||
href={MAC_KEY_FAQ_URL}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
查看 macOS 获取密钥排障指引
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -654,7 +654,7 @@ body {
|
||||
background-color: #fff;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
width: 300px;
|
||||
width: 360px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@@ -666,6 +666,27 @@ body {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.login-input-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.login-input-wrapper .login-input {
|
||||
padding-right: 40px;
|
||||
margin: 10px 0 10px 0;
|
||||
}
|
||||
|
||||
.login-input-toggle {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.login-btn {
|
||||
background-color: #07c160;
|
||||
color: #fff;
|
||||
@@ -673,12 +694,79 @@ body {
|
||||
padding: 8px 20px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.login-btn:hover {
|
||||
background-color: #06ad56;
|
||||
}
|
||||
|
||||
.login-btn:disabled {
|
||||
cursor: wait;
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.login-btn-secondary {
|
||||
border: 1px solid #cfd5d8;
|
||||
background: #fff;
|
||||
color: #30383d;
|
||||
}
|
||||
|
||||
.login-btn-secondary:hover {
|
||||
border-color: #07c160;
|
||||
background: #f2fbf6;
|
||||
color: #078f49;
|
||||
}
|
||||
|
||||
.login-clear-btn {
|
||||
margin-top: 10px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #8a949a;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.login-clear-btn:hover {
|
||||
color: #d33b3b;
|
||||
}
|
||||
|
||||
.login-key-status {
|
||||
margin-top: 10px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
background: #f3f5f6;
|
||||
color: #59636a;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
text-align: left;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.login-key-status.success {
|
||||
background: #eefaf3;
|
||||
color: #078f49;
|
||||
}
|
||||
|
||||
.login-key-status.error {
|
||||
background: #fff1f0;
|
||||
color: #c73737;
|
||||
}
|
||||
|
||||
.login-key-help-link {
|
||||
display: inline-block;
|
||||
margin-top: 9px;
|
||||
color: #1677ff;
|
||||
font-size: 12px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.login-key-help-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
|
||||
Reference in New Issue
Block a user