22 KiB
Authentication UX and analytics contract
Task: #12. Applies to login, signup through email verification, and standalone confirmation links.
User outcomes and interface
Signup leads to email delivery, then confirmation. Sent status shows a fixed recipient; Change email explicitly leaves that state and focuses the editor. A temporary confirmation failure prioritizes Retry verification, with requesting a replacement email secondary. Expired/invalid links offer replacement delivery. Demo labels sit beside the heading, and demo outcomes explicitly state that no email was delivered or account verified. The demo confirmation link is the primary next step on the simulated sent screen. EN/RU use the installed theme.
Measurement scope and attempts
Owner/consumer: the sxchnge product team, diagnosing onboarding completion and choosing between fixes to registration, delivery and confirmation recovery.
A journey starts at the first locally valid service request. This intentionally measures submitted attempts, not page views or field editing; pre-submit form abandonment and validation friction are outside this denominator. No generic click/field events are collected.
login: one requested login through its service result.signup: signup acceptance, email delivery requests, then email confirmation.verification: confirmation/replacement delivery when there is no active signup.
Retries reuse the journey ID and get a new operation number. Signup success and email delivery are intermediate states; confirmation success is the onboarding terminal outcome. Login success is terminal. Failed operations remain recoverable. Every operation has one completion, and every journey at most one terminal event. Changing auth mode, returning from verification to login, changing a confirmation token, or disposing the app ends the open attempt explicitly. A new operation after a terminal outcome starts a new journey.
The inactivity window is 30 minutes after the last settled nonterminal service result, reset by a new request. It is suspended while a service operation is pending: missing completion is not assumed to be abandonment. An explicit exit can end an attempt while work is pending; it does not cancel backend work. Late results cannot reopen that attempt or emit success after its abandonment.
IDs are random, scoped to this app instance and never derived from identity or link tokens. Same-instance signup → demo link is correlated. A real email opened in another tab/device or after reload starts a standalone verification journey. Cross-device signup conversion needs a future backend-issued, non-sensitive correlation contract; client code must never use email or the token as that ID. Tab termination is not reliably observable. Missing exits remain unknown, not inferred abandonment. Background timer throttling can delay idle detection.
Event schema (version 1)
| Event | Emission condition | Product decision supported |
|---|---|---|
auth_journey_started |
First valid request for a new attempt | Submitted-attempt denominator |
auth_{step}_requested |
A domain service operation starts | Locate the last meaningful step and retries |
auth_{step}_succeeded |
Service confirms a non-simulated result | Step conversion and outcome duration |
auth_{step}_simulated |
Dummy result, or terminal outcome of a mixed simulated journey | Validate the preview funnel without reporting business conversion |
auth_{step}_failed |
Service operation fails | Failure rates by safe error category and recovery |
auth_journey_abandoned |
Explicit exit or settled inactivity timeout | Diagnose known abandonment/drop-off |
step is login, signup, email_delivery, or email_confirmation.
Properties: schema_version, event_id, journey_id, journey_kind, sequence,
operation (per-journey counter when applicable), occurred_at (UTC), elapsed_ms
(monotonic journey duration), terminal, simulated, and optional typed failure/exit.
Every event in a known dummy journey is marked simulated. No email, password,
name, user ID, confirmation token, URL, raw exception, or form contents is captured.
Metrics
Group by journey_kind and journey_id; deduplicate delivery by event_id, ordering by sequence. Use closed attempts (terminal outcome or explicit abandonment) for settled cohort rates; report open/unknown counts separately. For a fixed reporting cohort, include starts in that interval and allow their defined inactivity window to settle. Report pending operations separately even after it.
- Completion: distinct successful terminal journey IDs / closed journey IDs.
- Simulated completion: same calculation using simulated terminals, reported separately. Never mix these into actual account/session conversion.
- Failure rate: distinct failed operations / requested operations; also report journeys with any failure / started journeys.
- Recovery: closed journeys completing after a failed operation / closed journeys with a failed operation.
- Known abandonment: abandoned journey IDs / closed journey IDs, by exit reason.
- Step drop-off: closed journeys reaching a step but not the next / closed journeys reaching that step (signup acceptance → delivery → confirmation).
- Duration: terminal elapsed_ms; step latency from matching requested/result operation timestamps. Report distributions separately for retries and failures.
Current service results are client evidence only. Future production adapters must return success only after server confirmation; delivery API acceptance still isn't proof that the email reached an inbox. That remains a documented proxy.
Dummy service and PostHog replacement
sxchnge_analytics is a platform-independent shared package. AnalyticsService
accepts typed AnalyticsEvent; DummyAnalyticsService retains the latest 500
unique events in memory for tests/debugging. It exposes an immutable event list
and clear(), writes no logs/files, installs no SDK, and makes no network requests.
It is diagnostic storage, not a durable metrics backend; older events are evicted.
Inject the service through SxchngeApp(analyticsService: service). Auth services
are decorated by InstrumentedAuthServices, so rebuilds, locale changes and
layout changes cannot generate events. Analytics exceptions are swallowed at the
recorder boundary and analytics futures are never awaited by auth operations.
Adapters must do lightweight capture/enqueue work; synchronous CPU blocking in an
adapter cannot be isolated by a Dart Future.
A future PostHog adapter should implement capture using event.name and the allowlisted event.properties. Official API reference: https://pub.dev/documentation/posthog_flutter/latest/posthog_flutter/Posthog-class.html
Preserve event_id and sequence for deduplication/reconstruction. Configure consent, retention, region and anonymous identity before enabling collection. Do not enable autocapture, session replay, automatic URL capture or user identification as a side effect of this adapter: confirmation URLs and authentication forms contain sensitive data. No PostHog key or network integration is included in this task. Retries/buffering in a future adapter must preserve the original event_id and occurred_at, remain bounded, and never delay authentication.
UX review and verification
- Purpose/primary action: explicit demo outcomes and primary confirmation retry.
- Context/recovery: confirmed recipient separate from editor, focus restored by Change email, no sent claim while editing; service failure retains input.
- States: initial/loading/sent/verified/expired/invalid/internal error remain available. Restricted/offline backend behavior is outside this dummy service.
- Responsiveness: EN/RU and 200% text coverage retained; focused tests cover keyboard submission, semantic heading/live region and mobile target labels/sizes.
- Measurement: tests cover correlated success, simulated outcomes, failures, retries, duplicate completion/delivery, explicit exit, pending operations, inactivity, and throwing/failing/non-completing analytics adapters.
- Privacy: payload tests exclude supplied form values and tokens; only typed allowlisted properties are exposed. Dummy buffer is bounded and read-only.
- Remaining limits: no real PostHog delivery or cross-device correlation is claimed. Automated semantics checks do not replace assistive-technology testing on supported devices; a native VoiceOver/TalkBack audit remains a release check.
Recorded validation
The automated suite passes 68 tests: 7 analytics, 20 auth, 3 theme, 3 theme consumer, and 35 app tests. Formatting and analysis pass for the three affected packages; the web build passes. Browser walkthrough at 390px covers internal-error hierarchy, secondary email recovery, actual Tab/Enter submission, and the demo sent screen. The heading is exposed as a live accessibility heading. Recipient text uses a normal text node to remain exposed to assistive technology.
Delivered in PR #13, branch
onboarding-12. The final browser accessibility tree exposes the recipient text,
demo status, status heading and recovery actions. PR is open for review.
Browser credential autofill
Task: #56, branch auth-56.
Delivery: PR #58, implementation
commit 86b6aaf. Open for review; no merge or deployment is included.
The consumer auth form groups its mounted inputs in one AutofillGroup and
declares their purpose explicitly:
| Input | Flutter hints | Browser autocomplete |
|---|---|---|
| Signup name | name with the name keyboard |
name |
| Account email | username, then email, with the email keyboard |
username |
| Login password | password |
current-password |
| Signup password and confirmation | newPassword on both |
new-password |
Flutter uses the first hint on web and Apple platforms. Email is the credential username; the email keyboard remains configured separately. Visibility toggles keep the password role, so autofill does not depend on detecting masked input. The confirmation field participates in the same group as the first password, allowing the browser to fill both without copying one controller into the other.
Signup acceptance finalizes autofill before clearing password controllers and navigating to email verification. Login finalizes on success; when a second factor is required, it waits for verification and login completion. The password remains only in the existing form controller during that challenge, then clears on completed navigation or cancellation. Invalid input, rejected credentials and failed verification do not request saving. Switching auth mode, abandoning the form or leaving for unverified-account recovery cancels the autofill context. Provider/service/model boundaries and existing request/outcome telemetry are unchanged; no field events or credential telemetry are added. Browser popup display and pre-submit autofill abandonment remain outside current measurement.
Verification and release check
Focused widget tests send a platform autofill response with all four signup values and check EN/RU controller updates, grouped credential metadata, visibility changes, mode switching and abandonment. Auth tests cover validation failures, service rejection/retry, stale responses, signup/login acceptance, unverified recovery, invalid second-factor codes and cancellation without premature saving. Existing narrow/desktop layouts and enlarged-text tests cover the unchanged UI.
Native Safari verification remains a release check: computer-control permission was unavailable in this session. On the deployed HTTPS origin with Safari AutoFill enabled, focus the signup email and password fields, choose a suggested strong password, verify that both password fields populate, then confirm that login offers the saved credential. Repeat after showing/hiding passwords and switching modes. Browser settings, stored credentials and browser heuristics determine whether suggestions appear; automated Flutter tests cannot establish that the system popup was displayed or that credentials were saved.
References: Apple Password AutoFill, Flutter autofill hints, and AutofillGroup.
Recorded validation: dart format --output=none --set-exit-if-changed passes for
all five changed Dart files; flutter analyze passes in frontend/app;
make test-frontend passes all 212 tests across the app and shared packages.
flutter build web passes. The built page's default rendering path was inspected
in the in-app browser: login exposes username/current-password in one HTML
form; signup exposes username, name and two new-password inputs in one form.
This verifies the browser metadata, not Safari's credential popup. With Flutter
web accessibility semantics enabled, unfocused semantic inputs initially use
framework-default autocomplete values until focused; password-manager behavior
with assistive technology still requires a native browser audit.
Verification links and login handoff
Task: #60, branch
email-verification-60. Email links use /verify-email?token=…; the web entry
point installs Flutter's path URL strategy before initializing the application.
This matches Caddy's existing SPA fallback and preserves the path and query on a
fresh page load. Previously the default hash strategy could start at login and
never submit the activation token. Unknown routes retain the canonical login
fallback; invalid, expired and failed confirmation attempts remain on the
verification page with the existing recovery actions.
Activation consumes the token, checks the pending account and accepted signup
facts, and activates the account in the existing database transaction. Only
after a successful commit does the API return {verified: true, email: ...}.
Failure responses do not disclose the account email. Responses retain the
shared no-store policy. The frontend backend DTO and mapper produce a
VerificationResult; the verification provider exposes that result only in the
verified state and clears it on reset, loading and failure. A verified response
from an older backend without the optional email can still show success.
Success stays visible until the user chooses Continue to login. This replaces the misleading workspace action that could redirect an unauthenticated visitor back to login. The verified email travels through transient router extras into the editable login controller; the password starts empty and receives focus. Normal password and second-factor checks remain required. The email is not added to URLs, serialized history, persistent storage or analytics. A link opened in another tab or device can receive the verified address from the API without depending on the originating signup form's memory.
Existing requested/succeeded/failed email-confirmation journey signals remain the measurement source. Success continues to mean that the confirmation service returned a verified result, with simulated outcomes kept separate. No field or prefill event is needed. Existing limits around pre-submit abandonment, cross-device journey correlation and the in-memory analytics sink still apply.
Regression coverage includes transactional activation and rollback, returned email and failure privacy, used-token rejection, backend response mapping, provider identity clearing, EN/RU success-to-login navigation, editable prefill, empty focused password, and exclusion of email from serialized navigation state. The supplied private diagnostic token is not a test fixture or documentation input; it is kept out of repository sources and tracker records.
Validation and local release (#60)
Delivery: PR #61, implementation
commit 5f3b443. Reviewed the complete backend and frontend contribution against
their module guidelines and the UX/observability checklists. No task-caused
review findings remain. Existing recovery, loading and accessible status states
are retained; EN/RU widget tests and existing small-screen/text-scale coverage
pass. Browser startup was checked against a separately built demo with synthetic
tokens: direct success remains visible, Continue to login shows the verified
email with an empty focused password, and an expired link offers resend/back.
Native Safari was unavailable to automation, so browser-specific helper behavior
is not claimed as verified by this check.
On 2026-10-19, changed-file Dart formatting, flutter analyze in both affected
packages (frontend/app, frontend/packages/sxchnge_auth), make test-frontend
(217 tests), GOFLAGS=-p=1 make test, and make lint-backend passed. The focused
transactional activation integration test also passed independently. Initial
test-container startup and image-resolution attempts failed while Docker was
stalled. After the user-authorized Docker Desktop restart, the unchanged checks
passed; no assertions, timeouts or gates were weakened. No security scan was
requested or run.
The task branch was integrated locally into the existing identity-facts-57
stand checkout, preserving its unrelated configuration. Fresh stable image
versions/digests were resolved successfully with sh infra/local/tools.sh images.
Compose rebuilt BFF and frontend with build --pull bff frontend, then recreated
them and the Sendico session sidecar (which shares BFF's network namespace).
Docker restart recovery also unsealed Sendico's existing Vault with its saved
local key and restarted affected existing services in dependency order.
The updated BFF, frontend and Sendico sidecar are healthy. The authenticated
Sendico currency/ledger check passes. HTTPS serves the direct verification path;
the served JavaScript matches the new frontend image with SHA-256
b194c0cf5dcc62634c13c7fd3d2f7e482272cb7abc1e470c62629400bf2d5d21.
A live synthetic invalid token returns HTTP 400 without an email. The supplied
real token was checked read-only and remains unused and unexpired, with its
account pending verification, ready for the user to reopen the link. PR #61 has
not been merged into remote main; this local deployment does not imply a main
branch merge.
Accepted signup delivery (#62)
Task #62, branch
email-verification-62. On October 23, signup returned HTTP 202 and the local
SMTP provider accepted its message with status 250, while the user reported an
internal-error screen immediately after Create account. No subsequent
verification request from that attempt reached the BFF. The precise original
client exception was not captured; an ordinary isolated release reproduction
succeeded. The regression below deliberately injects an unavailable follow-up
send service to cover the post-acceptance failure path without claiming that
injected exception was the original cause.
Backend and preview signup mappers now return a VerificationDelivery domain
receipt with the recipient and simulation status. It means that signup accepted
the email-queue operation, not inbox delivery, account activation or a session.
The signup provider exposes it through its operation result. The form captures
the receipt before clearing password controllers, whose change listeners reset
auth state, and passes it as transient router extra. The verification provider
initializes directly in the sent state; mounting the page performs no send.
This replaces the backend adapter's hidden one-use signup cache. Every explicit
resend now invokes its service, including the first one after signup.
Routes without a receipt retain their existing behavior: unverified login may request an email, a fresh verification page asks for an address, and emailed links run confirmation. Recipient changes invalidate the displayed claim until another send succeeds. New receipts replace stale recipients. The route codec discards the receipt when serializing history, so reloads do not invent a queued result or retain the address. No token or password is included in the receipt.
The existing email-delivery requested/succeeded signals now follow the accepted signup receipt inside the instrumented service instead of a synthetic send on page mount. They stay in the signup journey, occur once for the accepted operation, and remain intermediate queue-acceptance signals. Resends retain separate attempts and failures; confirmation remains the terminal outcome. Simulation and privacy rules are unchanged, including the in-memory analytics and cross-device correlation limitations above.
Frontend review covered the complete app/auth contribution and preserved the
DTO, mapper, service, domain model, provider and UI boundaries. Regressions cover
accepted signup followed by unavailable resend, explicit retry, normalized
receipt mapping, provider initialization/reset, recipient replacement, transient
history, event correlation and exclusion of form values from analytics. EN/RU
flow tests run at 390px with 1.5x text scaling; existing keyboard, autofill and
accessibility suites also pass. Changed-file Dart format checks, analysis in
frontend/app and frontend/packages/sxchnge_auth, and make test-frontend
(221 tests) pass. The new injected-failure regression failed on the previous
implementation and passes with the receipt handoff. A release-browser fixture
with the same backend adapter and a failing resend service shows Check your
inbox after signup with no send call, then the expected error only after an
explicit resend. It sends no email. Native Safari was not automated.
The frontend image was rebuilt with freshly resolved stable digest pins and
recreated without restarting BFF. The served JavaScript matches the new image
at SHA-256 0a45ce6f0b4658b1f03d8e39b9e39fd3fbfcf6f1626cb3f17705dce2da130a6c.
Normal browser-trust HTTPS serves the frontend and API with HTTP 200.
sh infra/local/tools.sh verify passes all seven services, Vault/database
checks and authenticated Sendico currency/ledger reads. No new customer email,
activation or payment was submitted for this change, and no security scan was
requested or run. The user's pre-existing browser tab must reload to use the
new bundle; their original Safari exception remains unobserved.