fix: confirm deletes and refuse duplicate Provider IDs - #89
Merged
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 surfacedUnknown Profilefor a Profile that had just been deleted successfully. That error is what got reported.Store.Deletenow 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 isIsDefault, so an accidental Enter does nothing.The existing test here was fake.
"deletes a Profile after confirmation"stubbedwindow.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
customreserved. Reusing an ID silently replaced that Provider's endpoints and key, and an ID matching a built-in shadowed the catalog entry — after whichSaveProviderreapplies 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.Createrefuses a taken ID (user or built-in);Savestays an upsert, which editing and key rotation need. The import path passescreate: falsedeliberately — 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
patternattribute on all three ID fields had never worked. Browsers compilepatternas av-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 includingACME!!. 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 underv, 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 ./...cleanpnpm run test— 31 files / 234 tests passpnpm run build(includestsc --noEmit) — passNew Go coverage for
DeleteProfile(normal delete removes the secret too, repeat deletes succeed) and forStore.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
ppiois refused with a readable message while PPIO stays unmodified, and the delete confirmation blocks the delete. Regeneratedfrontend/bindingswith the pinned CLI perbuild/Taskfile.yml; the only change is the newcreatefield.🤖 Generated with Claude Code