Skip to content

fix: confirm deletes and refuse duplicate Provider IDs - #89

Merged
yujiezhang-ops merged 4 commits into
mainfrom
fix/delete-confirmation-and-provider-id
Aug 7, 2026
Merged

yujiezhang-ops merged 4 commits into
mainfrom
fix/delete-confirmation-and-provider-id

Conversation

@yujiezhang-ops

Copy link
Copy Markdown
Collaborator

Closes #86. Closes #87.

Delete confirmation and idempotent Profile delete (#86)

Deleting a Profile ran straight from the button's onClick — no confirmation, no undo — and the button was never disabled while the request was in flight. A double-click sent two deletes, and the second found the file already gone and surfaced Unknown Profile for a Profile that had just been deleted successfully. That error is what got reported.

Store.Delete now treats an already-absent Profile as success: the caller asked for it to not exist and it does not. The secret and the active pointer are still cleaned up, so a record that vanished out-of-band no longer becomes undeletable either.

Both delete buttons (Profile and Provider) gain a confirmation and a disabled={busy} state. The confirmation reuses the native Wails dialog that the update prompt and export flow already use rather than introducing an in-app modal — it is modal for free, cannot be dismissed by a stray click, and needs no styling in either theme. Neither button is IsDefault, so an accidental Enter does nothing.

The existing test here was fake. "deletes a Profile after confirmation" stubbed window.confirm, which the page never called, so it passed against a delete with no confirmation at all — precisely the bug it was named for. Replaced with the decline path, the accept path, and a second click while the first delete is in flight. The decline case is what makes the confirmation load-bearing; without it, deleting unconditionally still passes.

Duplicate Provider IDs (#87)

Saving a Provider was an unconditional upsert with only the literal ID custom reserved. Reusing an ID silently replaced that Provider's endpoints and key, and an ID matching a built-in shadowed the catalog entry — after which SaveProvider reapplies the Provider to every Agent bound to that ID, so a typo could repoint working Agents at another vendor's endpoint with no prompt.

Creating and editing are now distinct. That has to come from the caller: both arrive as a complete Entry, and an ID absent from disk is equally consistent with creating and with renaming, so the store cannot infer it. Store.Create refuses a taken ID (user or built-in); Save stays an upsert, which editing and key rotation need. The import path passes create: false deliberately — restoring a backup is supposed to overwrite.

The form no longer asks users to invent an identifier: it arrives prefilled with a free valid ID and states the rule inline instead of teaching it by rejection.

A silent bug found while verifying

The pattern attribute on all three ID fields had never worked. Browsers compile pattern as a v-flag regex, where a literal - inside a character class is a syntax error — so validation threw, the browser swallowed it, and the attribute accepted every value including ACME!!. Invisible because the Go validator caught bad input at save time. Escaping the hyphen restores client-side validation. The regression test reads the attributes out of the source and compiles them under v, rather than rendering the components, because the bug is in the attribute string: a DOM-inspecting test would assert a value the browser had already rejected.

Testing

  • go test ./... — all packages pass; go vet ./... clean
  • pnpm run test — 31 files / 234 tests pass
  • pnpm run build (includes tsc --noEmit) — pass

New Go coverage for DeleteProfile (normal delete removes the secret too, repeat deletes succeed) and for Store.Create (duplicate user ID and built-in ID both refused, and the original survives untouched).

Manually verified against the e2e fake-runner server: the prefilled ID appears with the rule beneath it, saving a Provider with ID ppio is refused with a readable message while PPIO stays unmodified, and the delete confirmation blocks the delete. Regenerated frontend/bindings with the pinned CLI per build/Taskfile.yml; the only change is the new create field.

🤖 Generated with Claude Code

yujiezhang-ops and others added 4 commits August 7, 2026 13:57
Closes #86.

Deleting a Profile ran straight from the button's onClick, with no confirmation
and no undo, and the button was never disabled while the request was in flight.
A double-click therefore sent two deletes, and the second one found the file
already gone and surfaced "Unknown Profile" for a Profile the user had just
deleted successfully. That error is what got reported.

Store.Delete now treats an already-absent Profile as success. The caller asked
for it to not exist and it does not; the secret and the active pointer are still
cleaned up, so a record that vanished out-of-band no longer becomes undeletable
either.

The delete button gains a confirmation and a disabled state. The confirmation
goes through the native Wails dialog the update prompt and the export flow
already use, rather than a new in-app modal: it is modal for free, it cannot be
dismissed by a stray click, and it needs no styling in either theme. Neither
button is marked IsDefault, so an accidental Enter does nothing.

The test named "deletes a Profile after confirmation" stubbed window.confirm,
which the page never called, so it passed against a delete with no confirmation
at all — precisely the bug it was named for. It is replaced by three cases: the
decline path, the accept path, and a second click while the first delete is still
running. The decline case is what makes the confirmation load-bearing; without
it, deleting unconditionally still passes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Closes #87.

Saving a Provider was an unconditional upsert, and only the literal ID "custom"
was reserved. Reusing an existing ID silently replaced that Provider's endpoints
and key, and an ID matching a built-in Provider shadowed the catalog entry.
SaveProvider then reapplies the Provider to every Agent bound to that ID, so a
typo could repoint working Agents at another vendor's endpoint with no prompt.

Creating and editing are now distinct. The distinction has to come from the
caller: both arrive as a complete Entry, and an ID that is not on disk is equally
consistent with creating a Provider and with renaming one, so the store cannot
infer it. Store.Create refuses a taken ID — user or built-in — and Save stays an
upsert, which is what editing and key rotation need. The import path passes
create: false deliberately, since restoring a backup is supposed to overwrite.

The form also stopped asking users to invent an identifier. It arrives prefilled
with a free, valid ID and says the rule inline, rather than teaching it by
rejection.

Also fixes the `pattern` attribute on all three ID fields, which had never
worked. Browsers compile `pattern` as a v-flag regex, where a literal `-` inside
a character class is a syntax error, so validation threw, the browser swallowed
it, and the attribute accepted every value — "ACME!!" included. It was invisible
because the Go validator caught bad input at save time. Escaping the hyphen
restores client-side validation, and a test reads the attributes back and
compiles them under `v` so this cannot regress silently again.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Covers the third ID field, on the Agent Profile page, and pins the fix.

The test reads the `pattern` attributes out of the source rather than rendering
the components, because the bug lives in the attribute string: a test that
inspected the DOM would happily assert a value the browser had already rejected
as an invalid regex. It compiles each one with the `v` flag and checks that a
legal ID passes while "ACME!!" and a leading hyphen do not.

Verified by reverting the escape: both assertions fail.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The E2E "Provider CRUD persists keys" test failed on the delete step: the button
did nothing and produced no error.

Dialogs.Question needs a WebView to host the dialog. Under `-tags server` — the
browser preview and the E2E build — there is none, and the call neither resolves
nor rejects: it never settles. The catch clause I relied on was therefore
unreachable, and every delete in server mode hung at the confirmation with the
handler parked before setBusy.

Confirmed by driving the real button under Playwright: no dialog event fired, the
record stayed listed, and the button was never disabled — so execution had not
reached the first statement after the await.

A hang cannot be caught, so the request is raced against a 1.5s timer and falls
back to window.confirm. The race carries a distinct sentinel object rather than a
boolean, because a native dialog the user declined must not fall through to a
second prompt — that would turn a "no" into another chance to say yes. Where
neither prompt can be shown, "cannot ask" means "do not delete".

Covered both directions: the hang, an outright rejection, and the declined case
that must not re-prompt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@yujiezhang-ops
yujiezhang-ops merged commit 80df57d into main Aug 7, 2026
4 checks passed
@yujiezhang-ops
yujiezhang-ops deleted the fix/delete-confirmation-and-provider-id branch August 7, 2026 09:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant