fix(buy): stop the completion mail step from silently swallowing its result - #1204
Conversation
…result
MailEdit is rendered by BuyCompletion when the account has no mail yet. Two
defects turned that step into a dead end:
BuyCompletion passed `onSubmit={(email) => (!email || email.length === 0) &&
close()}`, which for a non-empty address evaluates to `false && close()` and
therefore does nothing. After a successful save the confirmation screen stayed
exactly as it was — same address, same enabled Confirm button, no feedback — so
the user clicked Confirm again. The account now carried a mail, which the API
answers with 403 TFA_REQUIRED, and MailEdit rethrew that into an unhandled
promise rejection with no UI change at all, inviting yet another click. In
production this produced 1206 TFA_REQUIRED responses on PUT /v2/user/mail over
30 days against 19 successful sets.
MailEdit now handles the 403 the way kyc.screen.tsx already does — redirect to
/2fa with the Basic level — surfaces every other error through ErrorHint instead
of discarding it, and tracks its own in-flight state so Confirm is disabled
during the request regardless of the isUserUpdating flag from the react package.
BuyCompletion closes on a successful submit, matching what its own Close button
does in the branch for accounts that already have a mail.
…g out of the flow Follow-up to the previous commit, from review: The 2FA redirect it introduced was not an improvement. setRedirect stores only the pathname, while the completion view is component-local state in the buy screens, so returning from /2fa remounts an empty buy form: the typed address is gone, the buy completion screen is unreachable, and nothing explains why. That trades a visible dead end for a silent flow reset. This step can only ever set a FIRST address — changing an existing one also needs a code sent to the new address, and this component has no field to enter one — so it now says exactly that and leaves the user on the completion screen, where Close still works. Two more cases the previous commit routed into the generic error hint: - A 409 "account already exists - account merge request sent" reaches this step on the first-set path too. ErrorHint tells the user to try again, which only mails another merge request; the correct action is to follow the link already sent. It now shows the same hint the account and KYC screens use for this case. - A stale error survived the Change button, so a new address could be presented with the previous address's error still on screen. The new string is added to the German, French and Italian translations.
…er refresh The effect that applies the mail app-parameter compares it against the cached user address with a case-sensitive !==, but the API lowercases the address on write. A parameter such as ?mail=John.Doe@Example.com therefore never satisfies the guard: it fires the update again on every change of the user object, which is one of the ways an account that already carries an address ends up hammering PUT /v2/user/mail and collecting 403 TFA_REQUIRED. Comparing case-insensitively lets the guard close once the address has been applied.
…en and a way out Review found the previous commit inconsistent with its own reasoning. It moved the 409 merge case out of ErrorHint because ErrorHint's fixed opening line tells the user to try again, which is the wrong action there — then routed the 2FA case into ErrorHint, where retrying is just as futile: the account has an address, so every further PUT answers 403 and this component has no field for the emailed code. The user read "Please try again" directly above "you can change it in your account settings", with Confirm still enabled. It also had no exit. BuyCompletion renders its Close button only in the branch for accounts that already carry an address, and both host screens suppress the layout back button while the completion is showing, so this state could only be left through the burger menu. The case now gets the same treatment as the merge hint: its own short screen with an OK button that ends the step.
The case-insensitive guard from the previous commit only mirrored half of the server's normaliser. The API applies trim and lowercase, so a padded parameter such as ?mail=%20a@b.com is stored as a@b.com while the guard keeps comparing against the padded value — permanently open, and re-submitting on every change of the user object exactly as before. The parameter is persisted to local storage, so that survives reloads. The guard now normalises the same way the server does, and additionally remembers the address it already submitted. That also covers the two cases normalisation alone cannot close: a value the API rejects outright, and a change that is accepted with 202 and stays pending mail verification, where the stored address legitimately does not move. The normalised address is what gets sent, which is what the server would store anyway.
…do anything
close() calls closeServices and then blanks the component. On /buy/info that
combination strands the user: the screen passes navigateOnClose={false}, and
closeServices only acts when the app runs as a widget, inside an iframe, or with
a redirect URI configured — outside those it executes nothing at all. The body
then renders empty with the layout back button suppressed, leaving only the
burger menu.
That state predates this branch, reachable through the existing Close button and
the empty-address Finish path, but this PR routes three more outcomes into it. It
now falls back to the account screen when closing has nowhere to go.
|
Took 5 review passes to reach zero defects. The diff is small; the path to it was not, so here is what changed and why — several of these were regressions introduced by earlier revisions of this PR.
Two claims in this description were also wrong and have been corrected: a Close button that is not rendered in that branch, and
|
Keep why each branch exists — retrying cannot succeed, closing can be a no-op, the parameter guard has to normalise like the API — and drop the narration.
isEmbedded and canClose describe the host, not whether closeServices did anything. A widget host that leaves its optional close callback unbound, and a stored redirect URI that is discarded as unsafe, both satisfied the flags while nothing fired -- so the completion blanked itself and, with the back button suppressed, stranded the user. closeServices now reports whether a channel actually fired and the completion falls back to the account screen when none did. Three callers that returned its result from a Promise<void> function are adjusted accordingly.
The guard was set before the request and never taken back, so a single offline blip or a rate-limit response lost the mail parameter for the rest of the session. It now survives anything transient and keeps blocking only what retrying cannot fix, and the rejection is handled instead of escaping unhandled.
TaprootFreak
left a comment
There was a problem hiding this comment.
Reviewed with an independent second-model pass, then verified locally on a dedicated build host.
Verified
tsc -p tsconfig.build.json --noEmit,lint,build:devandwidget:dev— all clean (widget:devis the one that matters, since it runs without the pinnedCI=falseand treats warnings as errors)- Full suite: 64 suites, 622 tests green
- Mutation-tested both new files: removing the fallback navigation fails exactly
falls back to the account without blanking the completion when no close channel fired; removing the retry release fails exactlyretries the normalized parameter after a transient rate-limit error. Neighbours stay green. - One suite (
transaction-document-error) failed once under heavy load on the build host and passes both in isolation and in a full rerun — a timing flake, not a defect.
Changed on this branch (37d5cf7, ae58301)
- The new fallback keyed on
isEmbedded/canClose, which describe the host rather than whethercloseServicesdid anything. Two reachable holes stayed open: a widget host that leaves the optional close callback unbound (closeServicesonly doesprops.closeCallback?.(...)), and a stored redirect URI discarded as unsafe (canCloseisredirectUri != null, but the sink drops it at!isSafeRedirectUri). Both left the screen blank with the back button suppressed.closeServicesnow returns whether a channel actually fired. - Three callers returned that value from
Promise<void>functions, which does not compile (TS2322insell.screen.tsx:493,sell-info.screen.tsx:231,payment-info.tsx:113) — adjusted to a plain call plusreturn. - The mail-parameter guard was set before the request and never released, so one offline blip or a
429— a status this route is about to start returning once DFXswiss/api#4439 lands — lost the parameter for the rest of the session. It now releases on anything transient and keeps blocking only what retrying cannot fix; the rejection is handled instead of escaping unhandled.
Note on the API-side counterpart
That coupling is worth stating explicitly: api#4439 introduces the rate limit that makes the transient case reachable in the first place. The two PRs declare each other optional, which is true for the dead end itself, but this particular interaction only appears once both are live.
Left open
No Playwright baselines for the two new screens. Not treated as a blocker: none of the 149 existing baselines covers the mail step, and the three specs mentioning "email address" only assert on page text without a screenshot, so nothing existing goes stale. Adding focused cases would still be worthwhile.
Disclosure: I contributed the commits above, so this approval covers my own changes as well.
|
@Danswar Heads-up: I pushed two commits to your branch (37d5cf7, ae58301) while reviewing. The Second commit: the mail-parameter guard was set before the request and never released, so a single offline blip or a Lint, type-check, |
The lockfile pinned 1.7.0-beta.0, so npm ci installed the prerelease even though the caret range already allowed the stable release. 1.7.0 is the one that carries the updateMail cache refresh this branch builds on. Brings @dfx.swiss/core 0.6.0-beta.0 -> 0.6.0 along as a transitive requirement of react 1.7.0; nothing else in the lockfile moves.
Problem
BuyCompletionrendersMailEditwhen the account has no mail yet — the "Is this email address correct? / [Change] [Confirm]" step at the end of a buy. Two defects make it a dead end.The success path does nothing.
buy-completion.tsxpassed:For a non-empty address that is
false && close()— a no-op.MailEditkeepsshowConfirmationtrue andpendingEmailset, so it re-renders the identical screen.user.mailis still stale (the react package'supdateMaildoes not refresh it), soBuyCompletionstays in theMailEditbranch too. The user gets no confirmation, no navigation, nothing — and clicks Confirm again.The second click's error is discarded. The account now has a mail, so the API requires 2FA and answers
403 {"code":"TFA_REQUIRED", ...}.saveUseronly handled the merged-account 401 and rethrew everything else into a promise nobody catches — an unhandled rejection with zero UI change, which invites another click. The Confirm button is never disabled either: it receivesisLoading={isUserUpdating}, and that flag is never set for this call.Production,
PUT /v2/user/mailover 30 days: 1206TFA_REQUIREDagainst 19 successful sets. A representative session:Reported by a partner-wallet user who saw the 403 and assumed the step had failed. It had not — their address was stored on the first click.
Changes
Buy completion mail step
buy-completion.tsxcloses on a successful submit, which is what its own Close button does in the branch for accounts that already have a mail.isSavingstate disables Confirm while the request is in flight, so the fix does not depend on a@dfx.swiss/reactrelease landing first.ErrorHint— whose fixed opening line ("Something went wrong. Please try again…") is the wrong instruction for both:TFA_REQUIRED— the account already carries an address. This step can only ever set a first one; changing an existing one additionally needs a code mailed to the new address, and this component has no field to enter one.409 "account already exists - account merge request sent"— reachable on the first-set path too. Retrying only sends another merge mail; the user has to follow the link. Reuses the hint the account and KYC screens already show for this case.close()falls back to the account screen whencloseServiceshas nothing to do. On/buy/info(navigateOnClose={false}, not a widget, not in an iframe, no redirect URI) it executed nothing, and blanking the component left an empty body with the back button suppressed. That state predates this branch, but this PR routes three more outcomes into it.Mail app-parameter (
settings.context.tsx) — a second source of the same 403sThe comparison is raw, but the API normalises the address on write (
@Transform(Util.toLowerCaseTrim)plus atoLowerCase()indoUpdateUserMail) and themailparameter is never normalised on the client. So?mail=John.Doe@Example.com— or a paddeda@b.com— never satisfies the guard: the effect re-submits on every change of theuserobject, and once the address is stored each of those is another403 TFA_REQUIRED. The parameter is persisted to local storage, so it survives reloads.The guard now normalises the same way the server does, and remembers the address it already submitted. That bound also covers the two cases normalising alone cannot close: a value the API rejects outright, and a change accepted with
202that stays pending mail verification, where the stored address legitimately does not move.Why not redirect to
/2faAn earlier revision of this PR did redirect, mirroring
kyc.screen.tsx. Review showed that is worse than the dead end it replaces:setRedirectrecords only the pathname (navigation.hook.ts), while the completion view is component-local state (buy.screen.tsxshowsCompletion,buy-info.screen.tsx). Returning from/2fatherefore remounts an empty buy form — typed address gone, completion screen unreachable, no explanation. Routing to/account/mailinstead was considered; it sends the user on an errand they usually do not need, since reaching this branch at all means the account already has a valid address.Known limitation (pre-existing, not addressed here)
When the account already has a mail and a valid 2FA log exists,
PUT /v2/user/mailanswers202and mails a verification code instead of403.updateMailis typedPromise<void>and every 2xx resolves identically, so the client cannot distinguish202from200and will close as if the address were stored — when it is only pending. Narrow (it needs a staleuser.mail, a recent same-IP 2FA log, and a different address) and unchanged by this PR. It applies to theMailEditstep only — the app-parameter path above is bounded by its submitted-address guard. Closing it properly needs the status code surfaced through@dfx.swiss/reactplus the token field this component lacks; tracked for a follow-up rather than smuggled in here.Verification
The four CI commands pass locally on Node 20.20.2 / npm 10.8.2:
npm run lint,npm run test(62 suites, 617 tests),npm run build:devandnpm run widget:dev.npx tsc -p tsconfig.build.json --noEmit, the type check the review bot runs, is also clean.widget:devis the build that matters for strictness:scripts/build-widget.shdoes not pinCI, so GitHub Actions runs it with warnings-as-errors.scripts/build.shpinsCI=falseinline, in the repo, sobuild:devnever enforces that gate anywhere.(A bare
npx tsc --noEmitis not a usable gate in this repo and is not part of CI:tsconfig.jsonsets"typeRoots": ["typings"], so@types/jestnever resolves and the test files produce thousands ofCannot find name 'describe'errors ondevelopas well. None of them touch the files in this PR.)Related
updateMailrefreshes the cached user and setsisUserUpdating. This PR does not wait on it.