fix(tradein-frontend): MapPicker focus trap + Leaflet SRI, PhotoUpload size check #514
No reviewers
Labels
No labels
admin
analytics
auth
automation
bug
business
chore
ci
compliance
data
data-moat
docs
duplicate
dx
enhancement
Fable 5 ревью
feedback/max
generative
GG-форсайт
needs-discussion
needs-human
observability
pause-bots
performance
priority/p0
priority/p1
priority/p2
priority/p3
scope/backend
scope/db
scope/devops
scope/frontend
scope/qa
scrapers
security
site-finder
stage/1
stage/2
status/blocked
status/done
status/needs-analysis
status/needs-fix
status/qa
status/ready
status/review
status/wip
tech-debt
tradein
ux
week ревью 1
wontfix
вторичка
ИРД
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: lekss361/gendesign#514
Loading…
Add table
Reference in a new issue
No description provided.
Delete branch "feat/tradein-frontend-isolated-ux"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Two isolated UX/security fixes from 2026-05-24 trade-in audit.
MapPicker.tsx(#12)role=dialog,aria-modal=true,aria-labelledby="map-picker-title"on the inner dialog div.document.activeElementon mount, restore on unmount.sha256-integrity hashes andcrossOrigin="anonymous". Defends against unpkg CDN hijack. Hashes computed locally viacurl -sL ... | openssl dgst -sha256 -binary | openssl base64 -Aand verified to match the values committed:leaflet.js→sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=leaflet.css→sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=PhotoUpload.tsx(#13)MAX_FILE_BYTES = 10 * 1024 * 1024mirrors backend_MAX_PHOTO_BYTES. Rejects oversized files before upload (no wasted bandwidth, no waiting for 413).error: string | nullwitherrors: string[], render as<ul>. Each failed file gets its own line with name + reason + (for size errors) actual MB.setErrors([])on every new file pick — old failures don't linger after a successful subsequent upload.file.size === 0before upload (mirrors backend 400 "empty file").Test plan
<link>and<script>injected byloadLeaflet(); both must haveintegrityandcrossorigin="anonymous".<script>and reload — browser must block the resource (SRI failure).<filename>: файл больше 10 МБ (50.0 МБ)instead of waiting for backend 413.Deep Code Review — verdict: APPROVE
Summary: Status ✅ APPROVE · Files: 2 (P1: MapPicker.tsx · P2: PhotoUpload.tsx) · +84 / -7 · scope-disciplined a11y + SRI + client size guard.
Verifications performed
SRI hashes — recomputed locally against
unpkg.com/leaflet@1.9.4:leaflet.js→sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=✓ matches commitleaflet.css→sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=✓ matches commitBackend constant alignment (
PhotoUpload.tsx:11↔tradein-mvp/backend/app/api/v1/trade_in.py:442):MAX_FILE_BYTES = 10 * 1024 * 1024==_MAX_PHOTO_BYTES = 10 * 1024 * 1024✓file.size === 0rejection mirrors backendif not content: 400 "empty file"✓WAI-ARIA dialog pattern (MapPicker):
role="dialog",aria-modal="true",aria-labelledby="map-picker-title"on inner panel ✓previouslyFocused.current = document.activeElementsaved on mount, restored in cleanup ✓windowkeydown listener for Escape — properly removed in cleanup, no leak ✓onKeyDownon dialog (React synthetic; bubble OK since Escape listener is window-level keydown — different event semantics, no conflict) ✓a[href], button:not([disabled]), input/select/textarea:not([disabled]), [tabindex]:not([tabindex="-1"])— standard pattern ✓SSR safety (Next 15 app router):
"use client"directive on line 1 ✓window/documentaccess is insideuseEffectorloadLeaflet()(called from effect) — no SSR hazard ✓EstimateForm.tsx:439as{mapOpen && <MapPicker .../>}— focus effect runs only when modal opens ✓Effect lifecycle: focus-management
useEffecthas[onClose]dep. If parent passes a fresh function each render, cleanup re-runs (removeEventListener + refocus previouslyFocused) and then re-attaches. Behaviorally harmless becausepreviouslyFocused.currentis set inside the effect body, so a re-run between user keystrokes would re-recorddocument.activeElement(now likely the close button itself) — losing the original opener. Mitigation:EstimateForm.tsx:442passes() => setMapOpen(false)inline, which technically changes identity per render of EstimateForm. In practice EstimateForm only re-renders when its own state changes (mostly tied to form fields), and during an open MapPicker the dialog backdrop covers the form so user interactions don't propagate field-changes — risk is theoretical for this codebase. Non-blocking for this PR; flag for follow-up: either wraponCloseinuseCallbackat the call site or readonClosevia ref inside the effect.Error array overwrite (
PhotoUpload.tsx:60):setErrors(collected)replaces accumulated errors at end of batch. The PR description claims "old failures don't linger after a successful subsequent upload" — verified: pre-loopsetErrors([])clears stale, post-loopsetErrors(collected)shows current-batch errors only. Correct.Key prop on error
<li key={i}>: index-as-key is acceptable here because the list rebuilds atomically per batch and is never re-ordered/inserted-in-the-middle. Non-issue.Cross-file impact
MapPickerconsumer:EstimateForm.tsx:439-444(single call site) — props unchanged, no breakage.PhotoUploadconsumer: search shows it's mounted on the trade-in estimate detail page; state shape change (error: string | null→errors: string[]) is internal, no prop API change.<script data-leaflet>selector check still works; new SRI attrs are applied only on first inject (when no element exists). Re-opens reuse existing tags — fine, integrity already enforced on first load.Vault cross-check
audits/TradeIn_MVP_CodeReview_May23.md(40 findings) does not previously call out MapPicker a11y or Leaflet SRI — these are net-new improvements aligned with the audit's general direction (XSS allowlist hardening, C-6 pattern).decisions/Decision_TradeIn_DataQuality_8PR_Roadmap.mddoes not conflict.Non-blocking follow-ups (do NOT block this PR)
unpkg.comis third-party CDN with no SLA — long-term consider self-hosting Leaflet infrontend/public/vendor/. Out of scope for this fix.[onClose]— see Effect lifecycle note above; consideruseCallbackat call site or ref-in-effect pattern.Positive
// match backend _MAX_PHOTO_BYTES).focus-trap-reactwould be overkill for one modal).setErroroverwrote previous failures inside the loop — visible bug pre-PR).Complexity / blast radius
Squash-merge approved.