fix(settings): 避免凭据加载前被空值覆盖 (#82)

* 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

* 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
This commit is contained in:
Chris233 authored and GitHub committed 2026-04-30 14:58:53 +08:00
1 parent e3f91e849c
commit df8846913a
3 files changed
+37 -17

No files matched your search

+3
View File
@@ -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',
+3
View File
@@ -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: '快捷键速查',
+31 -17
View File
@@ -336,6 +336,8 @@ function ProvidersSection() {
);
}
type CredentialFieldStatus = 'idle' | 'saving' | 'saved' | 'readError' | 'saveError' | 'copied' | 'copyError';
interface CredentialFieldProps {
label: string;
account: string;
@@ -351,14 +353,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<CredentialFieldStatus>('idle');
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const statusRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
let cancelled = false;
setLoaded(false);
setDirty(false);
setStatus('idle');
setValue('');
if (debounceRef.current) {
clearTimeout(debounceRef.current);
debounceRef.current = null;
@@ -373,7 +377,7 @@ function CredentialField({ label, account, placeholder, mono, mask, defaultValue
if (cancelled) return;
console.error('[settings] failed to read credential', account, error);
setLoaded(true);
setStatus('saveError');
setStatus('readError');
});
return () => {
cancelled = true;
@@ -383,26 +387,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: CredentialFieldStatus) => {
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<HTMLInputElement>) => {
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 +436,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 (
<SettingRow label={label}>
@@ -447,13 +458,13 @@ function CredentialField({ label, account, placeholder, mono, mask, defaultValue
<input
type={inputType}
value={value}
placeholder={placeholder}
placeholder={loaded ? placeholder : t('common.loading')}
onChange={handleChange}
onBlur={onBlur}
disabled={!loaded}
disabled={disabled}
style={{ ...inputStyle, fontFamily: mono ? 'var(--ol-font-mono)' : 'inherit' }}
/>
{defaultValue && !value && (
{defaultValue && !value && loaded && (
<button onClick={fillDefault} title={t('settings.providers.fillDefault')} style={iconBtnStyle} disabled={!loaded}>
<Icon name="check" size={13} />
</button>
@@ -463,6 +474,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}
>
<Icon name="eye" size={14} />
</button>
@@ -471,7 +483,7 @@ function CredentialField({ label, account, placeholder, mono, mask, defaultValue
onClick={onCopy}
title={t('common.copy')}
style={iconBtnStyle}
disabled={!value}
disabled={!value || disabled}
>
<Icon name="copy" size={14} />
</button>
@@ -484,12 +496,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')}
</span>
)}
</div>