From 44701c0335155f2a495509222a071e3cbbdaa936 Mon Sep 17 00:00:00 2001 From: H-Chris233 Date: Thu, 30 Apr 2026 14:29:28 +0800 Subject: [PATCH 1/2] Prevent untouched credential fields from clearing secrets Settings credential inputs can mount empty while readCredential is still pending. The field now waits for a successful read before enabling saves, tracks whether the user actually edited the value, and surfaces read failures without writing an empty credential back to storage. Constraint: Issue #34 requires preserving existing credentials when the settings page is opened or blurred without edits. Constraint: Keep secrets out of logs and UI. Rejected: Add full E2E coverage now | larger test harness work than needed for the minimal bug fix. Confidence: high Scope-risk: narrow Tested: npm install Tested: npm run build Tested: cargo check Tested: git diff --check --- openless-all/app/src/i18n/en.ts | 3 ++ openless-all/app/src/i18n/zh-CN.ts | 3 ++ openless-all/app/src/pages/Settings.tsx | 48 +++++++++++++++---------- 3 files changed, 36 insertions(+), 18 deletions(-) diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 826e4a2f..6781dfaf 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -19,7 +19,9 @@ export const en: typeof zhCN = { show: 'Show', hide: 'Hide', saved: 'Saved', + saving: 'Saving…', copied: 'Copied', + operationFailed: 'Operation failed', add: 'Add', }, capsule: { @@ -189,6 +191,7 @@ export const en: typeof zhCN = { asrWhisper: 'OpenAI Whisper (compatible)', }, fillDefault: 'Fill default value', + readFailed: 'Read failed', }, shortcuts: { title: 'Shortcut reference', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index a8b92296..fad775b9 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -17,7 +17,9 @@ export const zhCN = { show: '显示', hide: '隐藏', saved: '已保存', + saving: '保存中', copied: '已复制', + operationFailed: '操作失败', add: '添加', }, capsule: { @@ -187,6 +189,7 @@ export const zhCN = { asrWhisper: 'OpenAI Whisper(兼容)', }, fillDefault: '填入默认值', + readFailed: '读取失败', }, shortcuts: { title: '快捷键速查', diff --git a/openless-all/app/src/pages/Settings.tsx b/openless-all/app/src/pages/Settings.tsx index 666319f6..b9fa8c25 100644 --- a/openless-all/app/src/pages/Settings.tsx +++ b/openless-all/app/src/pages/Settings.tsx @@ -351,14 +351,16 @@ function CredentialField({ label, account, placeholder, mono, mask, defaultValue const [revealed, setRevealed] = useState(false); const [loaded, setLoaded] = useState(false); const [dirty, setDirty] = useState(false); - const [status, setStatus] = useState<'idle' | 'saving' | 'saved' | 'saveError' | 'copied' | 'copyError'>('idle'); + const [status, setStatus] = useState<'idle' | 'saving' | 'saved' | 'readError' | 'saveError' | 'copied' | 'copyError'>('idle'); const debounceRef = useRef | null>(null); + const statusRef = useRef | null>(null); useEffect(() => { let cancelled = false; setLoaded(false); setDirty(false); setStatus('idle'); + setValue(''); if (debounceRef.current) { clearTimeout(debounceRef.current); debounceRef.current = null; @@ -372,8 +374,8 @@ function CredentialField({ label, account, placeholder, mono, mask, defaultValue .catch(error => { if (cancelled) return; console.error('[settings] failed to read credential', account, error); - setLoaded(true); - setStatus('saveError'); + setLoaded(false); + setStatus('readError'); }); return () => { cancelled = true; @@ -383,26 +385,33 @@ function CredentialField({ label, account, placeholder, mono, mask, defaultValue useEffect(() => { return () => { if (debounceRef.current) clearTimeout(debounceRef.current); + if (statusRef.current) clearTimeout(statusRef.current); }; }, []); + const showTemporaryStatus = (next: typeof status) => { + setStatus(next); + if (statusRef.current) clearTimeout(statusRef.current); + statusRef.current = window.setTimeout(() => setStatus('idle'), 1600); + }; + const save = async (v: string) => { - if (!loaded) return; + if (!loaded || !dirty) return; setStatus('saving'); try { await setCredential(account, v); setDirty(false); - setStatus('saved'); + showTemporaryStatus('saved'); } catch (error) { console.error('[settings] failed to save credential', account, error); - setStatus('saveError'); + showTemporaryStatus('saveError'); } - window.setTimeout(() => setStatus('idle'), 1600); }; const handleChange = (e: React.ChangeEvent) => { const v = e.target.value; setValue(v); + if (!loaded) return; setDirty(true); if (debounceRef.current) clearTimeout(debounceRef.current); debounceRef.current = setTimeout(() => save(v), 300); @@ -425,21 +434,21 @@ function CredentialField({ label, account, placeholder, mono, mask, defaultValue }; const onCopy = async () => { - if (!value) return; + if (!value || !loaded) return; try { if (!navigator.clipboard?.writeText) { throw new Error('Clipboard API unavailable'); } await navigator.clipboard.writeText(value); - setStatus('copied'); + showTemporaryStatus('copied'); } catch (error) { console.error('[settings] failed to copy credential', account, error); - setStatus('copyError'); + showTemporaryStatus('copyError'); } - window.setTimeout(() => setStatus('idle'), 1600); }; const inputType = mask && !revealed ? 'password' : 'text'; + const disabled = !loaded; return ( @@ -447,13 +456,13 @@ function CredentialField({ label, account, placeholder, mono, mask, defaultValue - {defaultValue && !value && ( + {defaultValue && !value && loaded && ( @@ -463,6 +472,7 @@ function CredentialField({ label, account, placeholder, mono, mask, defaultValue onClick={() => setRevealed(r => !r)} title={revealed ? t('common.hide') : t('common.show')} style={iconBtnStyle} + disabled={disabled} > @@ -471,7 +481,7 @@ function CredentialField({ label, account, placeholder, mono, mask, defaultValue onClick={onCopy} title={t('common.copy')} style={iconBtnStyle} - disabled={!value} + disabled={!value || disabled} > @@ -484,12 +494,14 @@ function CredentialField({ label, account, placeholder, mono, mask, defaultValue }} > {status === 'saving' - ? '保存中' + ? t('common.saving') : status === 'saved' ? t('common.saved') : status === 'copied' - ? '已复制' - : '操作失败'} + ? t('common.copied') + : status === 'readError' + ? t('settings.providers.readFailed') + : t('common.operationFailed')} )} From 5cee315f9ffb69a86d53d06dfe49d0403b4c75c9 Mon Sep 17 00:00:00 2001 From: H-Chris233 Date: Thu, 30 Apr 2026 14:56:26 +0800 Subject: [PATCH 2/2] Keep credential fields recoverable after read errors Review feedback identified that read failures left fields disabled while the temporary status disappeared. Read errors now leave the field interactive with a persistent readError status, and the credential-field status union is named explicitly for safer helper signatures. Constraint: Preserve the issue #34 guard that only dirty loaded fields save credentials. Rejected: Broader state-machine rewrite | unnecessary for this targeted review fix. Confidence: high Scope-risk: narrow Tested: npm run build Tested: cargo check Tested: git diff --check --- openless-all/app/src/pages/Settings.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/openless-all/app/src/pages/Settings.tsx b/openless-all/app/src/pages/Settings.tsx index b9fa8c25..fd71926d 100644 --- a/openless-all/app/src/pages/Settings.tsx +++ b/openless-all/app/src/pages/Settings.tsx @@ -336,6 +336,8 @@ function ProvidersSection() { ); } +type CredentialFieldStatus = 'idle' | 'saving' | 'saved' | 'readError' | 'saveError' | 'copied' | 'copyError'; + interface CredentialFieldProps { label: string; account: string; @@ -351,7 +353,7 @@ function CredentialField({ label, account, placeholder, mono, mask, defaultValue const [revealed, setRevealed] = useState(false); const [loaded, setLoaded] = useState(false); const [dirty, setDirty] = useState(false); - const [status, setStatus] = useState<'idle' | 'saving' | 'saved' | 'readError' | 'saveError' | 'copied' | 'copyError'>('idle'); + const [status, setStatus] = useState('idle'); const debounceRef = useRef | null>(null); const statusRef = useRef | null>(null); @@ -374,7 +376,7 @@ function CredentialField({ label, account, placeholder, mono, mask, defaultValue .catch(error => { if (cancelled) return; console.error('[settings] failed to read credential', account, error); - setLoaded(false); + setLoaded(true); setStatus('readError'); }); return () => { @@ -389,7 +391,7 @@ function CredentialField({ label, account, placeholder, mono, mask, defaultValue }; }, []); - const showTemporaryStatus = (next: typeof status) => { + const showTemporaryStatus = (next: CredentialFieldStatus) => { setStatus(next); if (statusRef.current) clearTimeout(statusRef.current); statusRef.current = window.setTimeout(() => setStatus('idle'), 1600);