Skip to content

ECHO-1046 feat: login says what went wrong and when to try again - #1367

Open
JorimTheuns wants to merge 7 commits into
echo-1041-onboarding-measurementfrom
echo-1046-login-messages
Open

JorimTheuns wants to merge 7 commits into
echo-1041-onboarding-measurementfrom
echo-1046-login-messages

Conversation

@JorimTheuns

@JorimTheuns JorimTheuns commented Nov 10, 2026 •

Copy link
Copy Markdown
Member

Stacked on #1362

ECHO-1046. Between 12 Oct and 10 Nov, people who could not log in mostly saw "Something went wrong". Now each failure says what happened and what to do next.

What people see now

Every login error goes through describeAuthError. The cause comes from loginFailureCause.

Cause When Message
wrong_credentials INVALID_EMAIL_OR_PASSWORD, INVALID_EMAIL, wrong codes Unchanged and generic on purpose: it never says whether an account exists
unverified EMAIL_NOT_VERIFIED Unchanged, with ResendVerificationEmail
rate_limited 429, rate_limit.exceeded, TOO_MANY_ATTEMPTS LoginRateLimitNotice shows a countdown, and "Log in" stays disabled until it ends. The wait comes from Better Auth's X-Retry-After, from Retry-After, or from the platform's params.retry_after_seconds. If none of these is present, it waits 30 s (Better Auth's sign-in window). For a password login the message never mentions a code; the code flow keeps "request a new code".
blocked a 403 that carries no dembrane code (HTTP_403) LoginBlockedNotice says the login was stopped before it reached dembrane and suggests another network or browser. It offers "Email me a sign-in code instead" and support@dembrane.com, and it does not guess the cause.
network the request failed (Chrome, Firefox and Safari word this differently), the browser is offline, or the request timed out "We could not reach dembrane..."
server anything else "Something went wrong. Try again, and contact support if it keeps happening."
  • Failure after a successful sign-in: the sign-in now has its own try, separate from what runs after it (the device check and the landing page). If a later step fails, SignedInRecoveryNotice says "You are logged in..." and has a Continue button. It is not counted in user_login_failed. The emailed-code sign-in is split the same way.
  • "Forgot your password?" is always visible.
  • Registration: "Wrong address? Change email" goes back to the details step with the email field focused. The other answers, the ticked terms box and the password are kept.
  • Registration email check: the form now uses the same email rule as the server, so addresses like reg@example are refused on the form instead of being dropped silently by the server. The rule lives in platform/packages/account/src/email.ts and is mirrored in frontend/src/lib/emailRule.ts. A test on each side reads the other file and fails if the two patterns differ.
  • Password length is counted in code points in the browser, as passwordProblems counts it on the server, so an emoji counts as one character. The rule itself is unchanged.
  • Autocomplete hints: Register uses given-name, family-name, username on the email field, and new-password on both password fields. On the password step a hidden username field carries the email, because the email field is on another step. Login uses username and current-password.
  • Arriving with ?email=: this comes from /register and from invite links (AcceptInviteRoute). It still locks the address and sets off/new-password. That is deliberate: it stops a password manager from filling in a different saved account (QA audit 2026-04-23). The intent is now documented in the code.

Platform

  • RateLimiter.check: a refusal now includes retry_after_seconds when the counter knows when its window ends. Both counters do: the memory counter, and the Postgres counter through the same upsert, which now also returns reset_at. Counters without that information still refuse without a wait. No limit, window, key, store, password rule, session lifetime or verification requirement changed.
  • CORS: X-Retry-After and Retry-After are added to the exposed headers. Without this, the dashboard (on another origin) cannot read Better Auth's wait.

Analytics

  • user_login_failed now carries { error_code, cause, http_status }.
  • New events: login_rate_limit_shown { retry_after_s } and login_blocked_shown.
  • No event contains an email or anything else the person typed; a test asserts this.
  • Change: the authenticator prompt (2FA required, no code typed yet) is no longer counted as a failed login. A wrong authenticator code is counted as wrong_credentials.

Blocks

These are new in components/auth/: LoginRateLimitNotice, LoginBlockedNotice and SignedInRecoveryNotice. They take props in and send callbacks out, use Lingui strings and data-testid, and are tested in LoginNotices.test.tsx. I reused useResendCooldown, adding an optional start(seconds), and reused ResendVerificationEmail and PasswordRequirements. I did not use ErrorNotice on the login page, because it presents platform catalog codes and these are Better Auth errors. Register still uses it.

Translations

There are 7 new strings in src/locales, written by hand for nl, de, fr, es, it, uk and cs, each in the catalog's existing form of address. All of them are recorded in src/locales/machine-translations.json for review.

Verification

  • Frontend: check-path-case, pnpm lint, pnpm exec tsc, check-translations --strict, sync-error-codes --check, sync-accounts-contract --check, and vitest run (all: 241 files, 2014 tests passed, 1 skipped as before).
  • Platform: biome ci (one existing warning, in pricing), typecheck, packages check, and bun test for auth, account, ratelimit and apps/api/test/app.test.ts against a local Postgres 16. Result: 386 passed and 9 skipped. The skipped tests are the auth parity tests, which need the parity template, and they were not run.
  • New e2e e2e/register-verify-login.spec.ts: register, try logging in unverified, verify, then log in. I could not read the verification email from the test, so the spec mints the same HS256 token with E2E_AUTH_SECRET and skips when that is not set. I ran it once against a local API and dashboard and it passed. It is not a PR gate.
  • Login screenshots at 1280 and 390 px for each error state (wrong credentials, unverified, rate limited, blocked, network, server, failure after sign-in), and Register screenshots for an invalid email, the verify step and Change email. All of them were checked by eye.

Out of scope

  • Why the 403s happen (ECHO-1048).
  • Security controls (ECHO-1049).
  • Terms and DPA (ECHO-1050).
  • Google sign-in.

Found while running the e2e: Better Auth's own invalid-origin refusal is a 403 that carries a code. It reads as "server" here, not "blocked".

Fix round

  • The blocked (403) notice offers "Email me a sign-in code instead" once: while the notice shows, the same link under Log in is hidden, because every notice on this page carries its own way out (the signed-in notice's Continue works the same way). "Forgot your password?" stays.
  • The yellow outline on the disabled Log in button during the rate-limit countdown is the design system's intended disabled style ("pollen", --app-disabled: #ffd166 in styles/rules.css, applied in styles/button.module.css to every disabled control since frontend: the design system, theme layer and the first workstreams on it #1139), not a focus ring or a warning. Left as is; it is the same on Create project and Continue.
  • The seven German login and recovery messages this PR adds now say du, matching chore: German uses du throughout #1371 (model-written, in the ledger).

Generated by Claude Code

JorimTheuns and others added 4 commits November 10, 2026 13:34
The limiter's counters already know when their window ends; check() now
puts that in retry_after_seconds on rate_limit.exceeded. The dashboard may
read Better Auth's X-Retry-After (exposed through CORS). Registration's
email shape rule moves to its own module so the form can mirror it, with a
test that fails if the two differ. No limit, window, key or store changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BKdjPZBaxnCMragWmqzZTL
…again

Every login failure goes through describeAuthError with its own message:
wrong credentials (kept generic), unverified (with resend), rate limited
(countdown from the server's wait, Log in off until it ends), blocked
before reaching dembrane (other network or browser, the emailed code,
support), offline and server. A failure after the sign-in worked is no
longer a failed login: the person is told they are in and can carry on.
user_login_failed gains cause and http_status; login_rate_limit_shown and
login_blocked_shown are new. Forgot your password is always shown.

Registration applies the server's email rule, "Change email" goes back to
the email field with the other answers kept, the fields carry password
manager hints, and password length counts code points as the server does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BKdjPZBaxnCMragWmqzZTL
Seven new strings, written by hand in each catalog's form of address and
recorded in machine-translations.json for review.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BKdjPZBaxnCMragWmqzZTL
@JorimTheuns JorimTheuns added the journey label Nov 10, 2026 — with Claude
@linear

linear Bot commented Nov 10, 2026

Copy link
Copy Markdown

ECHO-1046

@JorimTheuns
JorimTheuns force-pushed the echo-1046-login-messages branch from c8152cc to 66cd01e Compare November 10, 2026 17:35
JorimTheuns and others added 3 commits November 10, 2026 18:58
While the blocked notice shows, it carries "Email me a sign-in code instead" and the
same link under Log in steps aside, as every notice carries its own way out (the
signed-in notice's Continue). Forgot your password stays.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BKdjPZBaxnCMragWmqzZTL
The seven German login and recovery messages this PR adds address the reader as du, as
the rest of German does once #1371 lands (model-written, in the ledger).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BKdjPZBaxnCMragWmqzZTL

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant