Skip to content

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

Merged
appergb merged 4 commits into
Open-Less:mainfrom
H-Chris233:main
Apr 30, 2026
Merged

fix(settings): 避免凭据加载前被空值覆盖#82
appergb merged 4 commits into
Open-Less:mainfrom
H-Chris233:main

Conversation

@H-Chris233
Copy link
Copy Markdown
Collaborator

@H-Chris233 H-Chris233 commented Apr 30, 2026

摘要

Fixes #34

本 PR 按最小改动修复设置页凭据字段在加载完成前失焦时,可能把已有密钥清空的问题。

此前 CredentialFieldreadCredential() 异步读取完成前,字段初始值可能仍为空。如果此时发生 blur,旧逻辑会保存当前空值,导致已有 ASR / LLM 凭据被覆盖或移除。

本次改动为凭据字段增加明确的 loading / ready / error 状态,并用 dirty 标记区分“用户实际修改”和“只是字段初始化为空”,避免打开设置页但未修改字段时写回空值。

修复 / 新增 / 改进

  • CredentialField 增加状态管理:

    • loading
    • ready
    • error
  • readCredential() 完成前禁用以下操作:

    • 输入
    • 复制
    • 显示 / 隐藏
    • 使用默认值按钮
  • 使用 dirtyRef 控制保存时机:

    • 只有用户实际修改过字段后,才允许 debounce 保存
    • 只有用户实际修改过字段后,才允许 blur 保存
  • readCredential() 失败时显示“读取失败”。

  • readCredential() 失败时不调用 setCredential(),避免用空值覆盖已有凭据。

  • 不在日志或 UI 中打印密钥明文。

兼容

  • 不包含:

    • 不改变凭据存储格式。
    • 不改变 provider 配置结构。
    • 不改变 ASR / LLM 调用逻辑。
    • 不引入新依赖。
    • 不新增大范围表单重构。
    • 不打印或暴露密钥明文。
  • 对现有用户 / 本地环境 / 构建流程的影响:

    • 已配置的凭据不会再因为打开设置页、字段尚未加载完成或旧窗口空字段失焦而被静默清空。
    • 用户实际修改字段后,仍会按原有保存路径写入凭据。
    • 读取失败时会显示错误状态,但不会覆盖已有凭据。
    • 构建流程无变化。

测试计划

  • 命令:npm run build

  • 结果:通过

  • 证据路径:本地构建输出

  • 命令:cargo check

  • 结果:通过,仅有既有 warning

  • 证据路径:本地检查输出

  • 命令:git diff --check

  • 结果:通过

  • 证据路径:本地命令输出

主要改动文件

  • openless-all/app/src/pages/Settings.tsx

备注

本 PR 仅修复凭据字段生命周期导致的空值覆盖问题,不扩大到 provider 配置、凭据存储格式或后端读取逻辑调整。

Summary by Sourcery

Prevent credential fields in the settings page from overwriting existing secrets with empty values before asynchronous loading completes, and surface clearer status feedback to users.

Bug Fixes:

  • Ensure credential values are not saved while credentials are still loading or when the field has not been modified, avoiding accidental clearing of existing ASR/LLM secrets.
  • Avoid calling the credential save logic when the initial asynchronous read fails, preventing existing credentials from being overwritten by empty values.

Enhancements:

  • Add explicit loading and read-error states and disable editing, copying, revealing, and filling defaults while credentials are loading, improving UX and safety.
  • Use transient status messages for saving, saved, copied, and failure states with localized strings for both English and Chinese.

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 Open-Less#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
@sourcery-ai
Copy link
Copy Markdown

sourcery-ai Bot commented Apr 30, 2026

Reviewer's Guide

Refines the Settings credential input lifecycle to distinguish loading/ready/error states, gate user actions until credentials are loaded, and ensure only user-initiated edits trigger saves, preventing empty-value overwrites while improving status feedback and i18n coverage.

Sequence diagram for credential loading and guarded save

sequenceDiagram
  actor User
  participant SettingsPage
  participant CredentialField
  participant CredentialStore

  User->>SettingsPage: openSettings()
  SettingsPage->>CredentialField: mountCredentialField(account)
  activate CredentialField
  CredentialField->>CredentialField: useEffect_loadCredential()
  Note over CredentialField: Initialize
  CredentialField->>CredentialField: setLoaded(false)
  CredentialField->>CredentialField: setDirty(false)
  CredentialField->>CredentialField: setStatus(idle)
  CredentialField->>CredentialField: setValue("")

  CredentialField->>CredentialStore: readCredential(account)
  alt read success
    CredentialStore-->>CredentialField: credentialValue
    CredentialField->>CredentialField: setValue(credentialValue)
    CredentialField->>CredentialField: setLoaded(true)
  else read failure
    CredentialStore-->>CredentialField: error
    CredentialField->>CredentialField: console.error(...)
    CredentialField->>CredentialField: setLoaded(false)
    CredentialField->>CredentialField: setStatus(readError)
  end

  User->>CredentialField: typeInInput()
  CredentialField->>CredentialField: handleChange(event)
  CredentialField->>CredentialField: setValue(newValue)
  alt loaded is false
    Note over CredentialField: return early, dirty stays false
  else loaded is true
    CredentialField->>CredentialField: setDirty(true)
    CredentialField->>CredentialField: clearTimeout(debounceRef)
    CredentialField->>CredentialField: debounceRef = setTimeout(save(newValue), 300ms)
  end

  User->>CredentialField: blurInput()
  CredentialField->>CredentialField: onBlur()
  Note over CredentialField: save() guarded by loaded && dirty
  CredentialField->>CredentialField: save(value)
  alt !loaded or !dirty
    Note over CredentialField: save() returns immediately
  else loaded && dirty
    CredentialField->>CredentialField: setStatus(saving)
    CredentialField->>CredentialStore: setCredential(account, value)
    alt save success
      CredentialStore-->>CredentialField: ok
      CredentialField->>CredentialField: setDirty(false)
      CredentialField->>CredentialField: showTemporaryStatus(saved)
    else save failure
      CredentialStore-->>CredentialField: error
      CredentialField->>CredentialField: console.error(...)
      CredentialField->>CredentialField: showTemporaryStatus(saveError)
    end
  end

  deactivate CredentialField
Loading

Class diagram for updated CredentialField state and behavior

classDiagram
  class CredentialField {
    - string label
    - string account
    - string placeholder
    - boolean mono
    - boolean mask
    - string defaultValue
    - string value
    - boolean revealed
    - boolean loaded
    - boolean dirty
    - Status status
    - TimeoutHandle debounceRef
    - TimeoutHandle statusRef
    + useEffect_loadCredential()
    + useEffect_cleanup()
    + showTemporaryStatus(nextStatus)
    + save(v)
    + handleChange(event)
    + onBlur()
    + fillDefault()
    + onCopy()
  }

  class Status {
    <<enumeration>>
    idle
    saving
    saved
    readError
    saveError
    copied
    copyError
  }

  class TimeoutHandle {
    <<type>>
  }

  CredentialField --> Status : uses
  CredentialField --> TimeoutHandle : uses

  %% Behavioral constraints
  class SaveConstraints {
    + boolean loaded
    + boolean dirty
  }

  CredentialField --> SaveConstraints : guards save

  %% Notes: save() only runs when loaded && dirty
Loading

State diagram for CredentialField loading and status lifecycle

stateDiagram-v2
  [*] --> Loading

  state Loading {
    [*] --> WaitingRead
    WaitingRead --> Ready : readSuccess
    WaitingRead --> ReadError : readFailure
  }

  Loading --> Ready : setLoaded(true)
  ReadError --> [*]

  Ready --> Saving : userEdits && blurOrDebounce
  Saving --> Saved : saveSuccess
  Saving --> SaveError : saveFailure

  Saved --> Ready : temporaryStatusTimeout
  SaveError --> Ready : temporaryStatusTimeout

  Ready --> Copied : copySuccess
  Ready --> CopyError : copyFailure

  Copied --> Ready : temporaryStatusTimeout
  CopyError --> Ready : temporaryStatusTimeout

  state Ready {
    [*] --> Idle
    Idle --> Saving : dirty && saveRequested
    Idle --> Copied : copySuccess
    Idle --> CopyError : copyFailure
  }

  state Saved {
    [*] --> DisplaySaved
    DisplaySaved --> Idle : temporaryStatusTimeout
  }

  state SaveError {
    [*] --> DisplaySaveError
    DisplaySaveError --> Idle : temporaryStatusTimeout
  }

  state Copied {
    [*] --> DisplayCopied
    DisplayCopied --> Idle : temporaryStatusTimeout
  }

  state CopyError {
    [*] --> DisplayCopyError
    DisplayCopyError --> Idle : temporaryStatusTimeout
  }
Loading

File-Level Changes

Change Details Files
Harden credential field lifecycle so reads/saves only occur in valid states and empty initial values are never written back.
  • Initialize field value to empty and reset status/state on account change, clearing pending save/status timers.
  • Treat read failures as a distinct readError state, keep loaded=false on failure, and surface a localized "read failed" message instead of a generic save error.
  • Gate save() on both loaded and dirty to avoid persisting untouched or not-yet-loaded values, and centralize temporary status transitions through a helper that auto-resets to idle.
openless-all/app/src/pages/Settings.tsx
Disable user interactions while credentials are loading to avoid acting on incomplete or placeholder state.
  • Short-circuit change handling before loaded to avoid marking the field dirty or scheduling debounced saves until the initial value has been read.
  • Introduce a disabled flag derived from loaded and apply it consistently to the input, default-value button, reveal toggle, and copy button so they are unusable during loading.
  • Prevent copying when the value is empty or not yet loaded, and update copy status handling to use the shared temporary-status helper.
openless-all/app/src/pages/Settings.tsx
Improve visual/status feedback and localization for credential operations.
  • Use localized strings for saving/copied/operation-failed/read-failed messages and for the loading placeholder instead of hard-coded text.
  • Only show the default-value button once credentials have loaded and the field is empty, aligning behavior with the new loading state.
  • Add new i18n entries for common.saving, common.operationFailed, and settings.providers.readFailed in both English and Chinese locales.
openless-all/app/src/pages/Settings.tsx
openless-all/app/src/i18n/en.ts
openless-all/app/src/i18n/zh-CN.ts

Assessment against linked issues

Issue Objective Addressed Explanation
#34 Ensure credential fields only save after readCredential() completes and only when the user has actually modified the field, so that opening the settings page without edits does not change credentials.
#34 On readCredential() failure, show an explicit read-error state and avoid writing empty values that could overwrite existing credentials.
#34 Avoid exposing secret values (credentials) in logs or UI (no plaintext key printing).

Possibly linked issues

  • [settings] 凭据字段加载前失焦会清空已有密钥 #34: PR adds loading/dirty states and blocks saves before read completes, preventing credentials.json from being cleared as described.
  • #N/A: PR implements saving/copying status messages, read error state, and error logging for credentials, matching issue requirements.

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

Copy link
Copy Markdown

@sourcery-ai sourcery-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 1 issue, and left some high level feedback:

  • When readCredential fails you set loaded back to false and use a temporary readError status that resets to idle, which leaves the field permanently disabled with a “loading” placeholder and no visible error after 1.6s; consider making the error state persistent and allowing some kind of recovery (e.g., setting loaded to true in the error path or introducing an explicit error/ready flag instead of overloading loaded).
  • The showTemporaryStatus helper types its next argument as typeof status, which is the runtime value type (string) rather than the union you defined in useState; it would be safer to extract a type Status = 'idle' | 'saving' | 'saved' | 'readError' | 'saveError' | 'copied' | 'copyError' and use that consistently for state and helper signatures.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- When `readCredential` fails you set `loaded` back to `false` and use a temporary `readError` status that resets to `idle`, which leaves the field permanently disabled with a “loading” placeholder and no visible error after 1.6s; consider making the error state persistent and allowing some kind of recovery (e.g., setting `loaded` to `true` in the error path or introducing an explicit `error`/`ready` flag instead of overloading `loaded`).
- The `showTemporaryStatus` helper types its `next` argument as `typeof status`, which is the runtime value type (`string`) rather than the union you defined in `useState`; it would be safer to extract a `type Status = 'idle' | 'saving' | 'saved' | 'readError' | 'saveError' | 'copied' | 'copyError'` and use that consistently for state and helper signatures.

## Individual Comments

### Comment 1
<location path="openless-all/app/src/pages/Settings.tsx" line_range="374-378" />
<code_context>
       .catch(error => {
         if (cancelled) return;
         console.error('[settings] failed to read credential', account, error);
-        setLoaded(true);
-        setStatus('saveError');
+        setLoaded(false);
+        setStatus('readError');
       });
     return () => {
</code_context>
<issue_to_address>
**issue (bug_risk):** Leaving `loaded` as `false` on read error keeps the field permanently disabled.

With `setLoaded(false)` in the read failure branch and `disabled = !loaded`, a read error now leaves the input and buttons permanently disabled, so users cannot fix or enter credentials after a failed fetch. Previously, setting `loaded` to `true` after a read error allowed editing despite the failure. Consider keeping `loaded` as `true` on read errors so the field remains interactive while `readError` is shown.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread openless-all/app/src/pages/Settings.tsx
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 Open-Less#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
@appergb appergb merged commit df88469 into Open-Less:main Apr 30, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[settings] 凭据字段加载前失焦会清空已有密钥

2 participants