Appearance
[UX] customer-portal — Warranty submission (public)
Draft from /ux-audit on 2026-07-30 (unattended batch run). Not filed. Repo: Adalen-Truck/customer-portal · Branch:
develop@693a76c· Files reviewed: 15 Patterns:forms/file-input,media/image-upload
Summary
The token verification itself is sound — verifyWarrantyToken runs server-side on every endpoint before any data is returned, and the form only renders after getContext succeeds, so SEC-02 passes. What fails is everything around it: the page carrying a 30-day write-capable bearer token in its URL is served with an allow-all robots.txt, no X-Robots-Tag and no Referrer-Policy (NAV-04), both file inputs are display: none and therefore unreachable by keyboard for a form whose mandatory field is a photo, and every load failure — network, 500, rate-limit — is rendered as "this link is invalid or has expired" with no retry and no way out. The single most important thing: this is the one route with no authenticated fallback and no sidebar, so each of those dead ends is terminal for a customer who is usually standing next to a broken machine on a phone.
Findings
1. Token URL is explicitly crawlable and leaks in Referer — Blocker · NAV-04
Where: apps/web/public/robots.txt:1-2, apps/web/nginx.conf:1-30, apps/web/index.html:1-24What: /warranty/:token (apps/web/src/router/index.ts:12) puts an HMAC-signed, 30-day, write-capable token in the path. None of the three required mitigations exist:
robots.txtisUser-agent: */Disallow:— an emptyDisallowis allow everything, so the app actively opts the token URL into indexing.apps/web/nginx.confsetsgzip, cache and SPA-fallback directives but noadd_header X-Robots-Tag "noindex, nofollow"and noadd_header Referrer-Policy "no-referrer". There is no other proxy or edge config in the repo, and nohelmetanywhere inapps/api.index.htmlhas no<meta name="robots">and no<meta name="referrer">, and no route in the app sets either at runtime.
Why it matters: the token is a bearer credential. Anyone holding the URL can read the customer's name, work-order name and their entire machine fleet (public-warranty.service.ts:99-118), upload photos, and submit the warranty claim on their behalf, for WORKORDER_LINK_TTL_DAYS (default 30). These links are sent to customers by SMS/email and get forwarded into group chats, pasted into tickets and synced through browser history — the one thing that must never happen next is a crawler indexing them into a permanent, searchable record. Recorded as Blocker because NAV-04 is a security rule and it fails on all three counts; honest caveat: exploitation still needs a prior URL leak, since the token is unguessable and PublicWarrantyThrottleGuard caps guessing at 60 req/min/IP. Fix: add to apps/web/nginx.conf a location ^~ /warranty/ block (before the SPA fallback) setting add_header X-Robots-Tag "noindex, nofollow, noarchive" always; and add_header Referrer-Policy "no-referrer" always;, and change robots.txt to Disallow: /warranty/. Header-enforced, per the rule — a runtime meta tag injected by Vue arrives after the crawler has already seen the response.
2. Both file inputs are keyboard- and screen-reader-unreachable — High · A11Y-03
Where: apps/web/src/pages/WarrantySubmissionPage.vue:438-445 (plate), :479-487 (other photos) What: each <input type="file"> carries class="hidden" (Tailwind display: none) inside a wrapping <label>. display: none removes the input from the tab order and from the accessibility tree; the <label> itself is not focusable and has no keyboard activation. There is no sr-only variant, no @keydown handler, and no proxy <button> calling input.click(). Why it matters: the plate photo is mandatory — canSubmit (:114-117) requires platePhoto, and the server independently rejects a submission without one (public-warranty.service.ts:233-237). A keyboard-only or screen-reader user therefore cannot complete the form at all; they reach a permanently disabled submit button with no explanation. The forms/file-input and media/image-upload patterns both list "image upload can be completed using keyboard alone" as a required check. Fix: swap class="hidden" for class="sr-only" (already used in this codebase at apps/web/src/components/mobile/MobileDetailHeader.vue:49), which keeps the input focusable and Space/Enter-operable, and add a focus-within: ring to the wrapping label so the focus position stays visible.
3. Every load failure is reported as "your link is invalid or has expired" — High · MSG-03
Where: apps/web/src/pages/WarrantySubmissionPage.vue:144-146What: onMounted wraps getWarrantyContext in a bare catch { state.value = "invalid" }. That catch swallows, identically: an expired/tampered token (400), a deleted work order (404), any 5xx, the 429 from PublicWarrantyThrottleGuard (public-warranty.throttle.guard.ts:27-29), a DNS or TLS failure, and simply being offline — which on a phone in a workshop or a field is the common case. Why it matters: the customer is told their link is dead when it is not, and the screen offers no retry. They stop, and the warranty claim is never filed; Ådalen sees a link that was never used and no signal that anything broke. Fix: inspect the error. asJson (warranty.api.ts:22-37) already has the Response; propagate the status. 400/404 → the existing invalid-link state. 5xx / TypeError from fetch (offline) → a separate retryable state with a "Try again" button that re-runs getWarrantyContext. 429 → "too many attempts, try again in a minute".
4. Uploaded photos disappear on reload and get duplicated into Dynamics — High · FORM-09
Where: apps/web/src/pages/WarrantySubmissionPage.vue:38-39, :47-53, :119-147; apps/api/src/modules/public-warranty/public-warranty.dto.ts:16-48What: WarrantyContextResponse returns the draft text fields (draftDescription, draftHoursCount, draftSelectedAssetExternalId, draftNewMachineName) but no photo list. platePhoto and otherPhotos live only in component memory, and the DraftStorage type persisted to localStorage deliberately excludes them. So after a reload the server still holds the photos — addPhoto attached them to the submission row (public-warranty.service.ts:138-151) — but the UI shows none, canSubmit is false, and the user must photograph the plate again. Why it matters: two harms. (a) Effort loss on exactly the step that is hardest on a phone: taking the plate photo means walking back to the machine. Reload is not an edge case here — iOS evicts the browser tab while the camera app is in the foreground. (b) Bad data downstream: enqueueSyncCommands (public-warranty.service.ts:318-329) iterates every photo on the submission, so the re-uploaded plate photo is pushed to the Dynamics work-order timeline alongside the orphaned first one, and the service technician sees duplicates with no way to tell which is current. Fix: add photos: { documentId, kind, previewUrl, fileName }[] to WarrantyContextResponse, populate it from documents.listRawByEntity(SUBMISSION_ENTITY_TYPE, submission.id) in getContext, and hydrate platePhoto/otherPhotos from it on mount alongside the existing draft fields.
5. Submit is disabled with no indication of what is missing — Medium · FORM-05
Where: apps/web/src/pages/WarrantySubmissionPage.vue:500, :114-117What: :disabled="!canSubmit", where canSubmit requires a non-empty description, a machine, and a plate photo. Only the plate photo is flagged as required (the * at :408); the machine field (:343) and the description (:395) carry no required marker, no hint, and no validation message anywhere in the component. The i18n files already contain warranty.errors.plateRequired, warranty.errors.machineRequired and warranty.errors.descriptionRequired in both en and nb — and grep finds zero references to any of the three in the page. The intended messages were written and then never wired up. Why it matters: a customer who fills in the description and uploads a photo but skips the machine select sees a greyed-out button and nothing to act on. This is the terminal step of the whole flow, on a page with no support nav. Fix: keep the button enabled, validate in submit(), and render the three existing message keys next to their fields (aria-describedby). Mark the machine and description fields required in the same style as the plate photo (:408).
6. Submit button is also disabled during submission — Medium · FORM-06
Where: apps/web/src/pages/WarrantySubmissionPage.vue:498-505What: canSubmit includes !submitting.value, and the button additionally gets :loading="submitting". The moment the user activates it, it becomes disabled. Why it matters: disabling a just-activated button moves focus to <body>. A keyboard or screen-reader user loses their place mid-submit and, because nothing is announced (finding 10), gets no indication the request is running or that it finished. submit() already guards re-entry with if (!canSubmit.value) return; at :218, so the disable buys nothing. Fix: drop submitting from canSubmit, keep the handler guard, and express the busy state with aria-busy="true" plus the already-translated warranty.submitting label.
7. Labels are not programmatically associated, and the new-machine field is mislabelled — Medium · FORM-01
Where: apps/web/src/pages/WarrantySubmissionPage.vue:343, :379, :395What: all three <label> elements are bare — no for — and the PrimeVue Select (:345), InputNumber (:383) and Textarea (:396) are given no id/input-id. Nothing associates them, so a screen reader announces an unnamed combobox, spinbutton and textarea. Worse, the machine <label> at :343 sits outside the v-if/v-else, so after the user clicks "Can't find your machine?" the free-text machine-name box at :363 is still labelled "Select machine" — and it has only a placeholder otherwise, which FORM-01 forbids as the sole label. The correct string, warranty.newMachineLabel ("Machine name" / "Navn på maskin"), exists in both locales and is never used. Fix: give each control an explicit input-id and point the for at it; move the label inside the branches and render newMachineLabel in the v-else. The media/image-upload reference markup shows the intended shape: <label for="upload-input">…</label><input id="upload-input" type="file">.
8. Upload failures are unactionable, and some files vanish silently — Medium · MSG-03
Where: apps/web/src/pages/WarrantySubmissionPage.vue:178-195, :158-176What: three defects in the same handlers.
onOtherSelected:185doesif (!isImageFile(file)) continue;— files the client rejects are dropped with no message at all. Select five photos, two of which the client's extension test misses, and two just never appear.- The
trywraps the whole loop, so the first failure abandons every remaining file; the message is the singular "Could not upload the photo. Please try again." with no indication of which succeeded. - The server caps uploads at 15 MB (
public-warranty.service.ts:23,:130, returning "Image is too large"), but:170-172and:189-191discard the server's message and substitute the genericuploadFailed. No size limit is stated anywhere in the UI. A customer with a 48 MP phone photo retries the same too-large file indefinitely.
Why it matters: the plate photo is the one mandatory artefact; an unexplained, repeatable failure on it ends the submission. forms/file-input requires that "files exceeding the size limit are rejected with an error message" and that "network interruption during upload shows an error with a retry option". Fix: state the accepted formats and the 15 MB cap in plateHint/photosHint before the user picks (the pattern's reference hint reads "PNG or JPG up to 5 MB"); check file.size client-side; per-file try/catch that continues the batch and reports "3 of 5 photos uploaded — <name> was too large".
9. Raw backend error strings reach the user, in the wrong language — Medium · MSG-02
Where: apps/web/src/pages/WarrantySubmissionPage.vue:231; apps/web/src/domains/warranty/warranty.api.ts:22-37What: submit() renders err.message verbatim. asJson builds that message from the API response body's message field — for a class-validator failure that is the raw array joined with ", ", and when the body is not JSON it falls back to the literal Request failed (${res.status}). Why it matters: an nb-locale customer who pastes a description over the 5000-char @MaxLength (public-warranty.dto.ts:83) is shown English class-validator text; a 500 shows "Request failed (500)". Neither says what to do next, and warranty.errors.submitFailed — which exists in both locales — is only reached when the thrown value is not an Error, i.e. essentially never. Fix: map known statuses to localized copy (400 → the specific field message, 409/410 → already-submitted, 5xx/offline → retryable) and default to warranty.errors.submitFailed.
10. No live regions anywhere in the flow — Medium · MSG-01
Where: apps/web/src/pages/WarrantySubmissionPage.vue:491-496, :253-271, :274-315What: the error paragraph has no role="alert". The loading skeleton has no aria-busy and no status text (warranty.loading exists in both locales and is never used — grep: 0 references). The invalid / already-submitted / success states each replace the entire <main> content with no role="status", and the success screen at :302-315 is a silent DOM swap. Photo uploads finishing produce only an <img alt=""> (see Baseline additions). Why it matters: a screen-reader user gets silence for every state change in this flow, including the one that matters most — "Thank you! Your inquiry has been received." They cannot tell whether they submitted successfully. WCAG 4.1.3. Fix: role="alert" on the error block, role="status" on the outcome states, aria-busy plus the warranty.loading text on the skeleton, and an aria-live="polite" region announcing each photo's filename on upload (the fileName is already captured at :169 and :187 and then never rendered).
11. The invalid-link dead end offers no route out — Medium · MSG-04
Where: apps/web/src/pages/WarrantySubmissionPage.vue:274-285What: the invalid/expired screen shows a warning icon and the copy "Please request a new link from Ådalen to submit your inquiry" — with no phone number, no email, no link. The page's only chrome is a logo and a language switcher (:240-249); it is deliberately outside AppLayout, so there is no nav, no footer and no contact route to fall back on. Why it matters: the customer is told to do something and given no means to do it. Warranty links expire after 30 days, so this state is reached routinely and not just through failure. Fix: add a mailto:/tel: contact affordance (and, if a self-service re-issue endpoint is added later, a "Request a new link" button) to the invalid state; the same affordance belongs on the already-submitted state at :288-299.
12. The page has no distinct document title — Low · NAV-03
Where: apps/web/index.html:22What: the title is the static "Ådalen App". Grep finds no document.title, no useHead, no meta.title and no router.afterEach anywhere in apps/web/src — no route in the app sets a title. Why it matters: here specifically, this is a link a customer keeps for days and returns to; their tab, history entry and bookmark all read "Ådalen App", as does every other page. Screen-reader users get no page announcement on load. Fix: app-wide, add meta.title to each route plus a router.afterEach that sets document.title. Because this affects all 37 features identically, it is probably better raised once at project level than repeated per feature — flagged here so it is not lost.
13. The two mode-switch text buttons are under the 24 px target — Low · A11Y-02
Where: apps/web/src/pages/WarrantySubmissionPage.vue:354-360, :367-373What: "Can't find your machine?" and "Back to the list" are <button>s styled text-sm font-medium text-accent with no padding — a ~20 CSS px line box, below the 24×24 WCAG 2.5.8 floor. forms/file-input's mobile checklist asks for 44×44 on touch. Why it matters: this is the only escape from a machine list that will not contain a customer's machine, on a phone, often with gloves on. Fix: add min-h-11 py-2 (or py-1.5 for the 24 px floor) and inline-flex alignment.
14. The success screen discards the reference the API returns — Low · CONTENT-03
Where: apps/web/src/pages/WarrantySubmissionPage.vue:222-229, :302-315What: submitWarranty resolves to SubmitWarrantyResponse { status, reference } (public-warranty.dto.ts:105-111), and the service populates reference with the work-order name (public-warranty.service.ts:264). The client awaits it and throws the value away. The success copy — "Ådalen has received the information and will be in touch if needed" — gives no reference, no timeframe, and no failure path. Why it matters: the customer leaves with nothing to quote and no idea when or whether to chase. "If needed" also leaves it ambiguous whether they should expect contact at all. Fix: render the returned reference, add a rough timeframe, and add the failure path ("if you have not heard from us within N working days, call …").
Unverified
- A11Y-01 (contrast) — not checkable from code. Worth measuring on a rendered page:
text-accent-strongonbg-accent-softin the dashed plate dropzone (:434),text-text-3onbg-sunkenfor the uppercase work-order eyebrow (:325), thetext-[10px]"Add photo" caption on the tile (:478), andtext-whiteonbg-inkin the 24 px remove buttons (:425,:469). - A11Y-06 (short viewport / mobile keyboard) — needs a rendered viewport. The page is a single scrolling column with no sticky affordances, which is promising, but the
auto-resizeTextarea (:396-401) growing with the keyboard open, the PrimeVueSelectwithfilter(:345-353) on a 320 px screen, and whether the wrap-around photo tile strip stays usable at 320 px all need checking at 320/360/390 px portrait perapps/web/CLAUDE.md. - Deployment headers beyond this repo. Finding 1 is based on
apps/web/nginx.confbeing the only proxy config in the tree and nohelmetinapps/api. If an edge proxy outside this repo already setsReferrer-Policy/X-Robots-Tag, part of that finding is moot — but the allow-allrobots.txtis not. - Whether
capture="environment"in fact suppresses the gallery option on the Android/iOS versions Ådalen's customers use (see Baseline additions) — the behaviour is browser-version-dependent and needs a device check.
CONTENT-01: passes. Every string on this page goes through t(), and the warranty.* block has full en/nb parity (36 keys each, verified key by key). Four keys are dead — newMachineLabel, loading, removePhoto, and the three errors.*Required — which is itself evidence for findings 5, 7 and 10.
Baseline additions
Three proposed rules, each seen here and each plausible in the other three projects:
- FORM-12 —
captureon a file input is used only when the artefact must be photographed live. On Android Chrome,captureopens the camera directly and removes the gallery/files option, so a user who already has the photo cannot supply it. Seen atWarrantySubmissionPage.vue:440and:481: both the mandatory plate photo and the optional "other photos" setcapture="environment". A customer filling the form in the office, or one who photographed the fault when it happened, cannot use the photo they already have.forms/file-input's mobile checklist ("file picker opens the correct source based oncapture") is the source. - A11Y-07 — Images that convey state or user-supplied content carry a text alternative;
alt=""is reserved for pure decoration. Seen atWarrantySubmissionPage.vue:418-422and:462-466: uploaded photo previews arealt="", and thefileNamecaptured at:169/:187is never rendered, so a screen-reader user has no confirmation a photo exists. Every remove button is named just "Remove" (:424,:468), so with four photos there are four identical, indistinguishable controls. - MEDIA-01 — Remote, data-bound images degrade to a placeholder, never the browser's broken-image glyph.
apps/web/CLAUDE.mdalready mandates<SafeImage>from@aadalen/uifor this; the page uses a bare<img>at:418and:462. Thesrcfalls back topreviewUrl, a token-gated API stream, whenever thumbnail generation failed (public-warranty.service.ts:155-160) — and if that stream errors the user sees a broken image where their plate photo should be. All four projects render remote images, so this generalises.
Cross-project note
- NAV-04 / NAV-03 are infrastructure-shaped and almost certainly recur. Neither
X-Robots-Tag/Referrer-Policyheaders nor per-route document titles exist anywhere in customer-portal; check the equivalent nginx/Caddy/hosting config and router in playout, tt-time-tracker and members. Any project with a token-in-URL route (password reset, email verification, magic link) inherits the NAV-04 exposure — including customer-portal's own queue items #4 and #5. - A11Y-03 hidden file input.
class="hidden"on<input type="file">inside a<label>is the standard styled-upload idiom, so it is likely wherever these apps take a file. Check playout and tt-time-tracker media uploads, and customer-portal #11 (trade-in machines) and #17 (checklist builder), which map to the samemedia/image-uploadslug. - FORM-05 / FORM-06 disabled submits were already confirmed in the playout password-reset audit; that makes two projects, so this is a programme-wide pattern rather than a local slip.
- FORM-09 upload-state-lost-on-reload applies to any flow where uploads precede submit — customer-portal #11 and #16 first, then playout's asset flows.