Skip to content

Commit 48265bb

Browse files
author
李杰
committed
release: v2.1.39
1 parent 1538685 commit 48265bb

5 files changed

Lines changed: 247 additions & 166 deletions

File tree

scripts/bump.cjs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
const fs = require('fs');
2+
const path = require('path');
3+
const date = new Date().toISOString().split('T')[0];
4+
5+
const rootDir = path.resolve(__dirname, '..');
6+
const pkg = require(path.join(rootDir, 'package.json'));
7+
const version = pkg.version.split('.').map((v, i) => i === 2 ? parseInt(v) + 1 : v).join('.');
8+
9+
const enMap = path.join(rootDir, 'CHANGELOG.md');
10+
const zhMap = path.join(rootDir, 'CHANGELOG.zh-CN.md');
11+
12+
const en = fs.readFileSync(enMap, 'utf8');
13+
const zh = fs.readFileSync(zhMap, 'utf8');
14+
15+
const enUpdate = '## [' + version + '] - ' + date + '\n### Added\n- Support pasting OTPAuth URI directly and extracting account name and secret.\n- Support inline editing of account name in MFA vaults.\n- Support uploading or pasting images containing QR code to auto-detect its MFA secret.\n';
16+
fs.writeFileSync(enMap, en.replace('## [Unreleased]', '## [Unreleased]\n\n' + enUpdate));
17+
18+
const zhUpdate = '## [' + version + '] - ' + date + '\n### Added\n- MFA 凭证可直接粘贴完整地址并通过内置解析器提取配置。\n- 添加列表记录的账号名二次点按修改。\n- 增设内置的图像读图识别能力,可上传或直接粘贴二维码解析 Secret。\n';
19+
fs.writeFileSync(zhMap, zh.replace('## [Unreleased]', '## [Unreleased]\n\n' + zhUpdate));
20+
21+
console.log('Changelog updated.');
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
const fs = require('fs');
2+
const path = require('path');
3+
4+
const localesDir = path.join(__dirname, '../src/locales');
5+
const newKeys = {
6+
'zh-CN': {
7+
'mfaVault.inputSinglePlaceholder': '在此粘贴 MFA 秘钥 / otpauth:// 地址 (支持直接粘贴包含二维码的截图)',
8+
'mfaVault.defaultAccountName': '未命名账户',
9+
'mfaVault.qrDetectFailed': '未能从图片中识别出有效的二维码,请确保图片清晰',
10+
'mfaVault.selectImage': '选择图片识别二维码',
11+
'mfaVault.clickToEdit': '点击修改账号名'
12+
},
13+
'en-US': {
14+
'mfaVault.inputSinglePlaceholder': 'Paste MFA Secret / otpauth:// URL (Supports pasting QR code screenshots)',
15+
'mfaVault.defaultAccountName': 'Unnamed Account',
16+
'mfaVault.qrDetectFailed': 'Failed to detect a valid QR code from the image. Please ensure it is clear.',
17+
'mfaVault.selectImage': 'Select image to recognize QR code',
18+
'mfaVault.clickToEdit': 'Click to edit account name'
19+
}
20+
};
21+
22+
const defaultMapping = newKeys['en-US'];
23+
24+
fs.readdirSync(localesDir).forEach(file => {
25+
if (file.endsWith('.json')) {
26+
const localeName = file.replace('.json', '');
27+
const data = JSON.parse(fs.readFileSync(path.join(localesDir, file), 'utf8'));
28+
const mappingToUse = newKeys[localeName] || defaultMapping;
29+
30+
let updated = false;
31+
for (const [key, value] of Object.entries(mappingToUse)) {
32+
if (!data[key]) {
33+
data[key] = value;
34+
updated = true;
35+
}
36+
}
37+
38+
if (updated) {
39+
// Sort keys alphabetically
40+
const sorted = {};
41+
Object.keys(data).sort().forEach(k => sorted[k] = data[k]);
42+
fs.writeFileSync(path.join(localesDir, file), JSON.stringify(sorted, null, 2) + '\n');
43+
}
44+
}
45+
});
46+
47+
console.log('Locales patched for new MFA features.');

src-tauri/Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/components/MfaVaultManager.tsx

Lines changed: 157 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,31 @@
1-
import { useEffect, useState } from 'react';
1+
import { useEffect, useState, useRef } from 'react';
22
import { useTranslation } from 'react-i18next';
3-
import { Copy, Plus, Trash2, Download, Upload, Check, Database } from 'lucide-react';
3+
import { Copy, Plus, Trash2, Download, Upload, Check, Database, Image as ImageIcon, Edit2, Save, X } from 'lucide-react';
44
import { save, open, confirm } from '@tauri-apps/plugin-dialog';
55
import { writeTextFile, readTextFile } from '@tauri-apps/plugin-fs';
66
import * as OTPAuth from 'otpauth';
7+
import jsQR from 'jsqr';
8+
9+
async function decodeQR(file: File | Blob): Promise<string | null> {
10+
return new Promise((resolve) => {
11+
const url = URL.createObjectURL(file);
12+
const img = new Image();
13+
img.onload = () => {
14+
URL.revokeObjectURL(url);
15+
const canvas = document.createElement('canvas');
16+
canvas.width = img.width;
17+
canvas.height = img.height;
18+
const ctx = canvas.getContext('2d', { willReadFrequently: true });
19+
if (!ctx) return resolve(null);
20+
ctx.drawImage(img, 0, 0);
21+
const imgData = ctx.getImageData(0, 0, canvas.width, canvas.height);
22+
const code = jsQR(imgData.data, imgData.width, imgData.height);
23+
resolve(code ? code.data : null);
24+
};
25+
img.onerror = () => resolve(null);
26+
img.src = url;
27+
});
28+
}
729

830
export interface MfaRecord {
931
id: string; // Internal use
@@ -105,10 +127,12 @@ export function MfaVaultManager() {
105127
return [];
106128
});
107129

108-
const [inputAccount, setInputAccount] = useState('');
109130
const [inputSecret, setInputSecret] = useState('');
110131
const [inputRemark, setInputRemark] = useState('');
111132
const [copiedId, setCopiedId] = useState<string | null>(null);
133+
const [editingId, setEditingId] = useState<string | null>(null);
134+
const [editValue, setEditValue] = useState('');
135+
const fileInputRef = useRef<HTMLInputElement>(null);
112136
const [timeRemaining, setTimeRemaining] = useState(() => {
113137
const now = Math.floor(Date.now() / 1000);
114138
return 30 - (now % 30);
@@ -128,24 +152,95 @@ export function MfaVaultManager() {
128152
}, []);
129153

130154
const handleAdd = () => {
131-
const act = inputAccount.trim();
132-
const sec = inputSecret.trim();
133-
if (!act || !sec) return;
155+
const raw = inputSecret.trim();
156+
if (!raw) return;
157+
158+
let accountName = t('mfaVault.defaultAccountName', '未命名账户');
159+
let extractedSecret = raw;
160+
161+
try {
162+
const parsed = OTPAuth.URI.parse(raw);
163+
if (parsed instanceof OTPAuth.TOTP) {
164+
const parts = [parsed.issuer, parsed.label].filter(p => !!p);
165+
if (parts.length > 0) {
166+
accountName = parts.join(': ');
167+
}
168+
extractedSecret = parsed.secret.base32;
169+
}
170+
} catch {
171+
extractedSecret = raw;
172+
}
134173

135174
const newRecord: MfaRecord = {
136175
id: createUniqueId(),
137-
accountName: act,
138-
secret: sec,
176+
accountName,
177+
secret: extractedSecret,
139178
remark: inputRemark.trim(),
140179
time: Date.now()
141180
};
142181

143182
setRecords(prev => [newRecord, ...prev]);
144-
setInputAccount('');
145183
setInputSecret('');
146184
setInputRemark('');
147185
};
148186

187+
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
188+
const file = e.target.files?.[0];
189+
if (!file) return;
190+
decodeQR(file).then(decoded => {
191+
if (decoded) {
192+
setInputSecret(decoded);
193+
} else {
194+
alert(t('mfaVault.qrDetectFailed', '未能从图片中识别出二维码,请确保图片清晰'));
195+
}
196+
});
197+
e.target.value = '';
198+
};
199+
200+
const handleDragOver = (e: React.DragEvent) => {
201+
e.preventDefault();
202+
};
203+
204+
const handleDrop = (e: React.DragEvent) => {
205+
e.preventDefault();
206+
const file = e.dataTransfer.files?.[0];
207+
if (file && file.type.startsWith('image/')) {
208+
decodeQR(file).then(decoded => {
209+
if (decoded) setInputSecret(decoded);
210+
});
211+
}
212+
};
213+
214+
const handlePaste = async (e: React.ClipboardEvent) => {
215+
const items = e.clipboardData?.items;
216+
if (!items) return;
217+
for (const item of items) {
218+
if (item.type.indexOf('image/') !== -1) {
219+
const file = item.getAsFile();
220+
if (file) {
221+
e.preventDefault();
222+
const decoded = await decodeQR(file);
223+
if (decoded) {
224+
setInputSecret(decoded);
225+
} else {
226+
alert(t('mfaVault.qrDetectFailed', '未能从图片中识别出二维码,请确保图片清晰'));
227+
}
228+
}
229+
}
230+
}
231+
};
232+
233+
const startEdit = (id: string, currentName: string) => {
234+
setEditingId(id);
235+
setEditValue(currentName);
236+
};
237+
238+
const saveEdit = (id: string) => {
239+
const trimmed = editValue.trim() || t('mfaVault.defaultAccountName', '未命名账户');
240+
setRecords(prev => prev.map(r => r.id === id ? { ...r, accountName: trimmed } : r));
241+
setEditingId(null);
242+
};
243+
149244
const handleDelete = async (id: string, accountName: string) => {
150245
try {
151246
const msg = t('mfaVault.confirmDeleteMsg', '确定要永久删除 [{{accountName}}] 的凭证记录吗?').replace('{{accountName}}', accountName);
@@ -288,21 +383,18 @@ export function MfaVaultManager() {
288383
</h3>
289384

290385
<div className="query-main" style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
291-
<div style={{ display: 'flex', gap: '12px' }}>
292-
<div className="form-group" style={{ marginBottom: 0, flex: 1 }}>
293-
<input
294-
type="text"
295-
placeholder={t('mfaVault.inputAccountPlaceholder', '账号 (Account Name) *')}
296-
value={inputAccount}
297-
onChange={e => setInputAccount(e.target.value)}
298-
/>
299-
</div>
386+
<div
387+
style={{ display: 'flex', gap: '12px', alignItems: 'center' }}
388+
onDragOver={handleDragOver}
389+
onDrop={handleDrop}
390+
>
300391
<div className="form-group" style={{ marginBottom: 0, flex: 2 }}>
301392
<input
302393
type="text"
303-
placeholder={t('mfaVault.inputSecretPlaceholder', '密钥 / 恢复码 (Secret) *')}
394+
placeholder={t('mfaVault.inputSinglePlaceholder', '在此粘贴 MFA 秘钥 / otpauth:// 地址 (支持直接粘贴包含二维码的截图)')}
304395
value={inputSecret}
305396
onChange={e => setInputSecret(e.target.value)}
397+
onPaste={handlePaste}
306398
style={{ fontFamily: 'var(--font-mono)' }}
307399
/>
308400
</div>
@@ -314,10 +406,28 @@ export function MfaVaultManager() {
314406
onChange={e => setInputRemark(e.target.value)}
315407
/>
316408
</div>
409+
410+
<input
411+
type="file"
412+
accept="image/*"
413+
style={{ display: 'none' }}
414+
ref={fileInputRef}
415+
onChange={handleImageUpload}
416+
/>
417+
418+
<button
419+
className="btn btn-secondary"
420+
title={t('mfaVault.selectImage', '选择图片识别二维码')}
421+
onClick={() => fileInputRef.current?.click()}
422+
style={{ flexShrink: 0, padding: '4px 10px' }}
423+
>
424+
<ImageIcon size={16} />
425+
</button>
426+
317427
<button
318428
className="btn btn-primary"
319429
onClick={handleAdd}
320-
disabled={!inputAccount.trim() || !inputSecret.trim()}
430+
disabled={!inputSecret.trim()}
321431
style={{ flexShrink: 0 }}
322432
>
323433
<Plus size={14} /> {t('mfaVault.add', '添加')}
@@ -368,8 +478,33 @@ export function MfaVaultManager() {
368478
const isWarning = timeRemaining <= 5;
369479
return (
370480
<tr key={record.id}>
371-
<td title={record.accountName} style={{ fontWeight: 500 }}>
372-
{record.accountName}
481+
<td style={{ fontWeight: 500 }}>
482+
{editingId === record.id ? (
483+
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
484+
<input
485+
type="text"
486+
value={editValue}
487+
onChange={e => setEditValue(e.target.value)}
488+
onKeyDown={e => {
489+
if (e.key === 'Enter') saveEdit(record.id);
490+
if (e.key === 'Escape') setEditingId(null);
491+
}}
492+
autoFocus
493+
style={{ padding: '2px 6px', fontSize: '13px', width: '100%', minWidth: '120px' }}
494+
/>
495+
<button className="action-btn is-success" onClick={() => saveEdit(record.id)}><Save size={14}/></button>
496+
<button className="action-btn" onClick={() => setEditingId(null)}><X size={14}/></button>
497+
</div>
498+
) : (
499+
<div
500+
style={{ display: 'inline-flex', alignItems: 'center', gap: '6px', cursor: 'text' }}
501+
onClick={() => startEdit(record.id, record.accountName)}
502+
title={t('mfaVault.clickToEdit', '点击修改账号名')}
503+
>
504+
<span style={{ maxWidth: '200px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'inline-block', verticalAlign: 'middle' }}>{record.accountName}</span>
505+
<Edit2 size={12} style={{ color: 'var(--text-tertiary)' }} />
506+
</div>
507+
)}
373508
</td>
374509
<td>
375510
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>

0 commit comments

Comments
 (0)