Skip to content

feat(auth): finish the email-change flow — verify, undo and a pending-change banner (#72) - #108

Merged
Adron merged 1 commit into
parity/queuefrom
issue/72-account-email-change
Sep 16, 2026
Merged

Adron merged 1 commit into
parity/queuefrom
issue/72-account-email-change

Conversation

@Adron

@Adron Adron commented Sep 16, 2026

Copy link
Copy Markdown
Member

Closes #72. Part of epic #71.

What changed

POST /api/auth/verify-email-change and POST /api/auth/undo-email-change are now wired (bodies
{ "token": … }), going through the existing safeApiCall → AppError mapping so 400/409/429
surface the server's own message. An Android user no longer has to finish an email change on the web.

Auth requirement was checked, not assumed. Both operations are x-auth-type: none,
x-subscription-tier: public, "security": [] in the spec — so neither needs a session, and the
undo path deliberately works signed-out, which matters because undo exists for the case where
the change was not the user's doing. There is a repository test asserting the call succeeds with the
session cleared.

Deep links

EmailChangeLink.parse() is pure JVM (no android.net.Uri) so it is unit-testable. It maps the
four link shapes onto VERIFY/UNDO + token, tolerating www., a trailing slash, extra query
params, fragments and percent-encoding — and returns null for a missing/blank token, a look-alike
host, the unrelated /verify-email link, or garbage.

It follows the app's existing NotificationLaunch pattern rather than navDeepLink:
MainActivity resolves intent.dataString through AuthRoutes.routeForEmailChangeLink(...). One
mechanism, no double navigation, deterministic whether the app starts signed in or out, and covered
by plain unit tests.

Also fixed in passing: AuthRoutes.RESET_DEEP_LINKS/VERIFY_DEEP_LINKS are now by lazy —
NavDeepLink parses its pattern with android.net.Uri, which would blow up any JVM test that merely
touched the AuthRoutes object.

Pending-change state

pendingEmail (confirmed present on the live GET /api/user, null when nothing is in flight) is
read on entry and after a request/resend, and rendered as a banner with the address and a Resend
confirmation email
action. Deliberately not cached in Room — it sits with the other "always
fresh" account surfaces and must never be shown stale, since it clears from a link tapped elsewhere.

Two things to confirm / decide

A. The emailed link URLs — high confidence, but please check the mail templates. They are not
documented, so I derived them live:

URL Result
GET /verify-email-change?token=probe 200, renders "Invalid Verification Link"
GET /undo-email-change?token=probe 200, renders "Invalid Undo Link"
/auth/verify-email-change, /email-change/verify, /account/verify-email-change, /change-email all 404

So the paths match the existing /verify-email and /reset-password convention and token is the
parameter name (it is also the field name in both API request bodies). Parsing is defensive around
token regardless, and both custom-scheme shapes are accepted, so a template using
interlinedlist:// would also work.

B. There is no "cancel pending change" endpoint, so I did not ship a fake button. The issue asked
for cancel; the API does not have it. Probed: DELETE /api/user/change-email/request → 405 (route
exists, POST only); POST /api/user/change-email/cancel → 404; DELETE /api/user/change-email → 404;
PATCH /api/user/update has no pendingEmail/email property.

The product's actual safety valve is undo-email-change, which needs a token from the email. So the
banner says plainly: "Your account keeps its current email until you open the link we sent to this
address. To stop the change, use the 'undo' link in the email sent to your current address."
If a
cancel endpoint is added later, resendEmailChange is the shape to copy.

Pre-existing discrepancy, left alone

The help page documents POST /api/user/change-email/request as { newEmail, password }, but the
OpenAPI spec and the already-shipped ChangeEmailRequest send only newEmail. Resend reuses that
existing call, so if the server really requires password, resend fails exactly the way the initial
request already would. Out of scope here, but worth verifying.

Verification

./gradlew :app:assembleDebug testDebugUnitTest --rerun-tasks → BUILD SUCCESSFUL, 941 tests, 0
failures
. New: EmailChangeLinkTest (14), EmailChangeViewModelTest (6),
AccountSettingsViewModelTest (12, up from 5), plus 5 in DefaultAuthRepositoryTest and 2 in
DefaultProfileRepositoryTest. Compose tests compile (assembleDebugAndroidTest green for
:feature:auth, :feature:profile, :app) but were not executed — no emulator.

Plumb the two endpoints that finish an email change and were previously
web-only, so an Android user can start and finish the change in the app:

- POST /api/auth/verify-email-change  — confirm the new address
- POST /api/auth/undo-email-change    — revert a change the user did not make

Both are unauthenticated in the OpenAPI spec (x-auth-type: none, empty
security), so the repository submits the token without requiring a session —
the point of the undo link is that whoever reaches for it may already be
locked out.

Deep links follow the app's existing NotificationLaunch pattern: a pure
EmailChangeLink parser maps the emailed URL onto an in-app route, MainActivity
resolves the VIEW intent through AuthRoutes.routeForEmailChangeLink, and the
nav host navigates there whether or not the app started signed in. Manifest
intent filters cover both https://interlinedlist.com/{verify,undo}-email-change
and the interlinedlist:// equivalents. Malformed links (no token, blank token,
foreign host, plain /verify-email) resolve to nothing and the screen reports an
invalid link rather than submitting or reporting success.

Account settings gains a pending-change banner driven by the live pendingEmail
field on GET /api/user: it shows the address awaiting confirmation, offers a
resend, and clears once the server reports the change confirmed or undone. The
API exposes no endpoint that cancels a pending change (DELETE on
/api/user/change-email/request is 405 and no /cancel route exists), so the
banner states plainly that the emailed undo link is how to stop it rather than
shipping a button that does nothing.

Tests: deep-link parsing for all four link shapes plus the malformed cases;
verify/undo ViewModel paths including the server's own error message; the
repository request shapes over MockWebServer; the pending state rendering,
clearing and resend on the account ViewModel; Compose coverage for the result
screen copy and the pending banner.

Closes #72
@Adron

Adron commented Sep 16, 2026

Copy link
Copy Markdown
Member Author

Merged into parity/queue. One conflict resolved on the way in: DefaultAuthRepositoryTest.kt gained session-teardown tests from #46 (PR #106) and email-change tests here. Both sets are independent additions to the same file, so both were kept. Re-verified after the merge — :app:assembleDebug testDebugUnitTest green.

@Adron
Adron merged commit 14bbe7f into parity/queue Sep 16, 2026
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.

1 participant