feat(error): friendlier generic error page (LC-552) #517

Merged
longjacksonle merged 2 commits from feat/LC-552-friendly-error-page into main 2026-07-07 05:28:05 +02:00

What

Improve the generic error page (LC-552). The report showed the bare "400 / Bad request / invalid or revoked code / Back to home" card and asked to improve its UX.

Context: the specific invite-code 400 in the screenshot was already fixed by LC-544 (#513, merged), which redirects a bad enclave code to a friendly discover banner. That exact string no longer exists in the tree. This PR improves the generic error page itself, which is still shown for every other handler error (403 / 404 / 409 / 413 / 429 / 500 and any other 400).

How

  • Always-present friendly copy - the page now shows a localized, human per-status description (en/es). A bare 404 / 403 used to render just a heading + button; now it reads as real copy ("We could not find that page...").
  • Redesign - a tone icon (warning triangle for 4xx, server glyph for 5xx), tinted theme-aware surface, clearer hierarchy, and a primary action. Light/dark via the existing design tokens.
  • Hide only internal detail - the truly-internal Internal variant (sqlx / askama / panic text) passes detail = None, so operator-facing strings never reach the client. It stays logged for operators.

A deliberate call on "no raw message"

The picked option leaned toward hiding raw messages. I kept the curated caller reason as a secondary detail line (e.g. "Pin cap reached (max 50)", "foreign_avatar must be a valid URL") rather than blanket-hiding it, because:

  • Those strings are intentional, user-facing validation feedback, not internal leaks.
  • Replacing them with a generic line is actively misleading - a pin-cap 409 would read "clashes with something that already exists".
  • The one truly-internal channel (Internal) is the real leak vector, and it stays hidden.

Net: friendly context always, helpful specific reason when the caller curated one, internal error text never. The detail stays HTML-escaped (Askama) and length-clamped. If you'd rather hide the curated reason too, it's a one-line flip (pass None for those variants) - say the word.

Note (out of scope)

The JSON API (/api/v1/*) shares AppError, so on error it renders this HTML page rather than a JSON error body. That predates this PR; worth a follow-up to give the API a JSON error path, but I kept LC-552 focused on the page.

Tests

Lib-level (so they run in CI) over AppError::into_response: BadRequest shows friendly copy + curated detail; Internal never leaks its sqlx/host text; NotFound renders human copy; TooManyRequests keeps Retry-After and shows its reason. Full just test / just test-saas green - existing tests that assert curated messages (pin cap, anti-spam block, bridge SSRF) still pass unchanged.

🤖 Generated with Claude Code

https://claude.ai/code/session_016e15V7qtQgNFaK3VNYpwkw

## What Improve the generic error page (LC-552). The report showed the bare "400 / Bad request / invalid or revoked code / Back to home" card and asked to improve its UX. Context: the specific invite-code 400 in the screenshot was already fixed by LC-544 (#513, merged), which redirects a bad enclave code to a friendly discover banner. That exact string no longer exists in the tree. This PR improves the *generic* error page itself, which is still shown for every other handler error (403 / 404 / 409 / 413 / 429 / 500 and any other 400). ## How - **Always-present friendly copy** - the page now shows a localized, human per-status description (en/es). A bare 404 / 403 used to render just a heading + button; now it reads as real copy ("We could not find that page..."). - **Redesign** - a tone icon (warning triangle for 4xx, server glyph for 5xx), tinted theme-aware surface, clearer hierarchy, and a primary action. Light/dark via the existing design tokens. - **Hide only internal detail** - the truly-internal `Internal` variant (sqlx / askama / panic text) passes `detail = None`, so operator-facing strings never reach the client. It stays logged for operators. ## A deliberate call on "no raw message" The picked option leaned toward hiding raw messages. I kept the *curated* caller reason as a secondary detail line (e.g. "Pin cap reached (max 50)", "foreign_avatar must be a valid URL") rather than blanket-hiding it, because: - Those strings are intentional, user-facing validation feedback, not internal leaks. - Replacing them with a generic line is actively misleading - a pin-cap 409 would read "clashes with something that already exists". - The one truly-internal channel (`Internal`) is the real leak vector, and it stays hidden. Net: friendly context always, helpful specific reason when the caller curated one, internal error text never. The detail stays HTML-escaped (Askama) and length-clamped. If you'd rather hide the curated reason too, it's a one-line flip (pass `None` for those variants) - say the word. ## Note (out of scope) The JSON API (`/api/v1/*`) shares `AppError`, so on error it renders this HTML page rather than a JSON error body. That predates this PR; worth a follow-up to give the API a JSON error path, but I kept LC-552 focused on the page. ## Tests Lib-level (so they run in CI) over `AppError::into_response`: BadRequest shows friendly copy + curated detail; Internal never leaks its sqlx/host text; NotFound renders human copy; TooManyRequests keeps `Retry-After` and shows its reason. Full `just test` / `just test-saas` green - existing tests that assert curated messages (pin cap, anti-spam block, bridge SSRF) still pass unchanged. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_016e15V7qtQgNFaK3VNYpwkw
Add error-desc-* keys (en/es), one per AppError variant, with human, actionable copy. These replace the raw internal error string (e.g. "invalid or revoked code") on the generic error page so end users never see developer-facing detail. Wired up in the following commit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016e15V7qtQgNFaK3VNYpwkw
feat(error): redesign the generic error page, hide only internal detail (LC-552)
All checks were successful
check-secrets / Nosey parker (push) Successful in 3s
check-secrets / Kingfisher (push) Successful in 4s
check-secrets / TruffleHog (push) Successful in 5s
check-secrets / Nosey parker (pull_request) Successful in 4s
check-secrets / TruffleHog (pull_request) Successful in 7s
check-secrets / Kingfisher (pull_request) Successful in 6s
Check / clippy + fmt + tests (pull_request) Successful in 3m16s
Create release / Create release from merged PR (pull_request) Has been skipped
a89fc0c0d8
Improve the standalone error page shown for handler-returned AppErrors (the bare "400 / Bad request / <raw msg>" card in the report). It now always shows a friendly, localized per-status description - so even a bare 404 / 403 reads as human copy instead of an empty heading - plus a tone icon (warning triangle for 4xx, server glyph for 5xx), tinted theme-aware surface, and a clear primary action.

The caller's curated reason (e.g. "Pin cap reached (max 50)", a validation message) still renders as a secondary detail line, because hiding it would drop genuinely helpful feedback and a generic line can be misleading. Only the truly-internal variant (Internal, carrying sqlx / askama / panic text) passes detail=None, so operator-facing strings never reach the client; the detail stays HTML-escaped and length-clamped. Unit tests (lib, so they run in CI) lock in: BadRequest shows friendly copy + curated detail, Internal never leaks its message, NotFound gets human copy, TooManyRequests keeps Retry-After and shows its reason.

Complements LC-544 (which redirected the one invite-code 400 to a friendly banner): every other error page now reads well too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016e15V7qtQgNFaK3VNYpwkw
longjacksonle scheduled this pull request to auto merge when all checks succeed 2026-07-07 05:26:30 +02:00
longjacksonle deleted branch feat/LC-552-friendly-error-page 2026-07-07 05:28:05 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
psa-systems/lets-chat!517
No description provided.