Admin console: surface failures instead of swallowing them - #318
Conversation
Seven destructive actions had no error path at all -- the mutation's error was never passed to the confirmation, which has no slot to render one. A failed delete looked exactly like a successful one that hadn't refreshed yet. This is reachable, not theoretical: App.delete is owner-only and OptOut.delete is narrower than OptOut.create, so a role that can record an opt-out cannot remove one. Fixes #309 -- delete user, delete role, retire app client, delete app, remove opt-out, rotate webhook secret, resubmit sender-id registration all now surface the real error message. Three confirmation components gained an errorMessage prop; the rest use InlineConfirm's existing one. No onError callbacks were added: TanStack Query populates mutation.error regardless, which is what the codebase's own working examples rely on. Fixes #310 -- webhook Replay dismissed its confirmation synchronously right after mutate(), so a failed replay was indistinguishable from a successful one. Moved into onSuccess, and the mutation is reset when the confirm is re-armed so a stale failure can't greet the next attempt. Fixes #311 -- a failed delivery-receipts fetch rendered the same "No delivery receipts recorded" empty state as a genuine zero-receipt result, on the screen whose whole purpose is diagnostic evidence. Error is now checked before the empty state and rendered distinctly. Fixes #312 -- Apps and Users claimed editing requires app:write / user:manage. Neither is enforced anywhere; both routes gate on hardcoded owner/admin roles. Copy now matches the schema and says so. Deliberately NOT adding the Layer-2 permission checks -- making those permissions real would change the security model and is a maintainer decision, though it remains the better end state. Fixes #313 -- three Messages strings still said "this app" while the banner directly above them said the list spans every app (since #211). Fixes #314 -- Simulate is disabled until App id is filled, with nothing saying so. Field marked required, hint added, title on the disabled button. The gating logic is untouched. Also fixed while in the same files: routes-screen's closeMore() reset createMutation and updateMutation but not deleteMutation, so a failed delete on one route showed its stale error when the confirm was next opened for a different one. Four stale module-doc comments claiming writes "cannot succeed until #194 lands" corrected -- #194 and #211 both landed. #308 is NOT fixed here, and its premise was wrong -- see that issue for the full retraction. The nine "blank" screens were an artifact of a non-composited test browser: React 19's Suspense reveal is requestAnimationFrame-gated, and Chromium suspends rAF for a page that never composites. Measured: document.hidden true, rAF never fires, setTimeout unaffected. What survives is a genuine UX weakness rather than a bug -- `<Suspense fallback={null}>` makes any slow boundary indistinguishable from a working page with no data, which is precisely why it took so long to diagnose. Eight screens now show a real skeleton; dashboard's Suspense wrapper is removed outright, since its own comment already said it wasn't needed and the screen uses no useSearchParams().
|
There was a problem hiding this comment.
🅵 Fast automated pass — SAST + a quick, diff-scoped look (no repo-wide retrieval). For a deeper, repo-aware review, mention @lightbridge-assistant on this PR.
All 34 changed files opened and reviewed (none left unopened). This is a UI-safety/UX PR: RouteSkeleton Suspense fallbacks across eight pages (component verified against Skeleton/ScreenStack and its props at every call site), error display threaded through confirm dialogs (all feeding verified InlineConfirm error/pending and InlineBanner props), a correct webhooks replay refactor (#310) and routes deleteMutation.reset() stale-error fix, a safe dashboard Suspense removal (confirmed no useSearchParams), and comment-only reachability-doc edits in the gateway/api routers (no code or security surface change). One P2 stands: opt-outs-screen (and the same newly-added rotate/replay error displays) renders a shared mutation-hook error never reset on dialog close, so a failed action leaks its error onto the next, different target — the same stale-error class this PR fixes in routes-screen. No P0/P1, no security findings.
🤖 AI-generated review — treat it as untrusted, verify before acting; a human owns the final decision (AI governance).
| <RemoveConfirmDialog | ||
| open={urlState.remove !== null} | ||
| pending={deleteMutation.isPending} | ||
| errorMessage={deleteMutation.error?.message} |
There was a problem hiding this comment.
Stale delete error shown on next target
RemoveConfirmDialog now renders deleteMutation.error?.message, but deleteMutation is never reset (checked closeRemoveConfirm/onOpenChange — they only clear urlState.remove). A failed remove leaves the error set; if the user cancels and re-opens Remove for a different opt-out, the stale error renders against a row it never applied to — the exact stale-error-on-shared-hook pattern this PR fixes in routes-screen.tsx via deleteMutation.reset() on close. Mirror that here (reset in closeRemoveConfirm or the dialog's onCancel/onOpenChange). Same latent pattern applies to the newly-added rotate/replay error displays in webhooks-screen.tsx (neither rotateMutation nor deleteMutation is reset on cancel/dismiss).
Was this useful? React 👍/👎 to give us feedback
|
Reviewed by verifying the claims against the tree rather than reading the description. Recommend merge. Verified"Seven destructive actions had no error path" — exactly seven, confirmed by count, not approximately: The reachability argument is real, not theoretical. Checked the schema directly:
Completeness: after the change every All gates pass on the branch: The #310 fix is the best thing hereMoving the dismissal from synchronously-after- Fixing the second-order effect of your own fix is the thing that usually gets missed. The #308 retraction — independently corroboratedRetracting your own filed issue with a mechanism rather than a hedge is the right call, and I can confirm the diagnosis from the other direction: I hit the identical artifact in this same repo today. My browser harness reported That is the same root cause: a browser that never composites suspends One inaccuracy
It is three, not four: On the deliberate non-changesThe Not adding Layer-2 gates for #312 is also right. Making |
Closes #309, #310, #311, #312, #313, #314. Does not close #308 — see below; I got that one wrong and have retracted it.
Summary
Seven destructive actions had no error path at all: the mutation's error was never passed to the confirmation, and three of the confirmations had no slot to render one. A failed delete looked exactly like a successful one that hadn't refreshed.
Intent
This is reachable, not theoretical.
App.deleteisowner-only;OptOut.deleteis narrower thanOptOut.create, so a role that can record an opt-out cannot remove one. That user clicks Remove, the spinner stops, the row stays, and nothing ever explains why.It also contradicts the codebase's own stated contract —
providers-screen.tsx's module doc says errors are "never swallowed, never silently retried", andInlineConfirmalready has anerrorprop that four sibling call sites use correctly. These seven were the exception.Scope
errorMessagemutate()intoonSuccess; mutation reset on re-armowner/adminroles, notapp:write/user:manage)titleon the disabled buttonTwo deliberate non-changes:
onErrorcallbacks. TanStack Query populatesmutation.errorregardless; the codebase's own working examples (EndpointDeleteConfirm,RouteDeleteConfirm) rely on exactly that. No-op callbacks would be dead code.mutationCache.onErrorbackstop. It fires for every mutation regardless of local handling, so it would double-report on all seven now-fixed sites — an inline banner and a contextless toast for the same failure. It protects only against a future forgotten call site. Reasonable to disagree; flagging as a decision rather than an oversight.app:write/user:managereal would change the security model — maintainer's call. It remains the better end state; the copy fix just stops the UI lying meanwhile.Also fixed in passing:
routes-screen'scloseMore()resetcreateMutation/updateMutationbut notdeleteMutation, so a failed delete on one route showed its stale error when the confirm next opened for a different one. And four module-doc comments still claiming writes "cannot succeed until #194 lands" — both #194 and #211 landed.#308 — retracted, not fixed
I filed #308 claiming 9 screens render permanently blank. That was wrong, and the full retraction with evidence is on the issue.
React 19's Suspense reveal is
requestAnimationFrame-gated; Chromium suspends rAF for a page that never composites. My test browser reporteddocument.hidden: true,rAF never fires,setTimeoutunaffected — which is why the app looked alive (React hydrated, tRPC returned data) while one rAF-gated step silently never ran. A real user can't hit it: noticing a page is blank requires looking at it, which foregrounds the tab, which makes rAF fire.What survives is a real UX weakness rather than a bug, and it's included here on those terms:
fallback={null}→ a realRouteSkeleton. A null fallback makes any slow boundary indistinguishable from a working page with no data — precisely why this cost so long to diagnose.dashboard/page.tsx:<Suspense>removed outright. Its own comment already said it "isn't strictly required here", and the screen uses nouseSearchParams().Verification
cargo xtask r6,pnpm --filter admin typecheck,pnpm --filter @vsms/ui typecheck,biome check(285 files) — all clean, on this branch rebased onto currentmain(which includes #316, touching two of the same files; both sides verified intact).Honest limitation: none of the error paths was click-tested. These fixes live on
/users,/apps,/opt-outs,/webhooks,/messages/[id]— and the only browser available here is the non-compositing one described above, so I can't drive them. Each was verified by tracing the wiring end to end (mutation → prop → the component that renders it), and I confirmed every added prop is actually read by its component rather than accepted and ignored. Nobody has yet watched a real 403 produce a real banner; that needs a human with a visible browser.One caught during development, worth noting: the first skeleton draft passed a block-level
SkeletonintoScreenHeader's<p>-wrapped description slot. Browsers auto-close<p>on a block child, silently splitting it into siblings — a real hydration-mismatch shape, reproduced before it was fixed.Risk Assessment
Presentation-layer only; no backend, schema or business logic. The realistic failure is a prop accepted but never rendered — which would relocate the silent failure rather than fix it, so that's what I checked per call site. The skeleton change is visual and reversible.
AI Usage Declaration
Reviewer Focus
The
mutationCache.onErrordecision — declining it means this class can recur silently, which is exactly how these seven arose. And #312's copy: I'd rather it be checked againstschemas/vsms.cstackdirectly than trusted, since replacing one wrong claim with another is the specific risk there.Checklist
docs/roadmap.mdchecked — no edit needed; no milestone, gate, dependency or decision changes.🤖 Generated with Claude Code