Admin UX + HEIC batch (fixes #135 #136 #137 #139)#16
Merged
Conversation
Storage layer's uploadFile() validates against an allow-list that omits heic/heif, so iPhone uploads (the route accepted them) threw "File type image/heic not allowed" downstream. Browsers — Chrome in particular — also can't render HEIC inline, so even if storage allowed the format, admins couldn't preview the doc photos. Convert HEIC/HEIF → JPEG in the upload route with the pure-JS heic-convert package (no native deps, safe for Railway containers). The conversion happens after MIME validation but before uploadFile(), so storage.ts's allow-list stays unchanged.
Production logs show "I-9 PDF field not found: US Social Security Number" on every submission. The bundled src/lib/i9-form.pdf (USCIS 01/20/25) does have that exact field, but admins can upload custom PDFs via I-9 Form Management, and older editions name the SSN field "SSN" — so the hardcoded mapping silently fails to fill the field on those forms. Make field resolution tolerant: when the mapped name isn't present in the loaded PDF, normalize names (lowercase, strip punctuation, collapse whitespace) and consult a small alias table for fields with known drift between editions. SSN gets aliases for "SSN", "Social Security Number", and "U.S. Social Security Number". Default mapping is unchanged (it matches the bundled form exactly).
fetchWithAuth used to do window.location.href = "/admin/login" with no
user-facing message on a 401. If an admin's session expired mid-form
(e.g. while filling out a hire date before approving), clicking Approve
fired the fetch, got 401, and silently navigated away — from the user's
perspective "nothing happened" and the unsaved form was gone.
Show an alert("Your session expired — please sign in again.") before
the redirect, and gate it behind a module-level flag so multiple
in-flight requests that all 401 simultaneously don't stack N alerts.
Browser alert is intentional pending an in-app toast primitive.
…36, #137)
(#136) Replace window.confirm() in approve/reject flow with an in-app
ConfirmDialog component — overlay + centered card, red-tinted confirm
button for reject, indigo for approve, Escape to dismiss, autofocus on
the confirm button. Built inline since the repo has no shared Dialog
primitive yet; can be extracted to src/components/ui later.
(#137) Make status-save failures actually visible. The old
handleStatusSave() wrapped the PATCH in a bare try/catch that swallowed
errors into a generic "Failed to update status" with no detail and no
error styling. Now:
- showMessage(text, tone) helper drives a red-tinted banner for errors
- non-OK responses extract the server's { error } body when present
- the catch surfaces the underlying Error.message
- same treatment for notes save, resend, and PDF download
Combined with the AdminContext alert (#137), an expired session now
either shows the expiry alert (if 401) or a visible red banner with the
actual server error (if 4xx/5xx) — instead of a silent click.
tx-joshg
added a commit
that referenced
this pull request
May 29, 2026
The /api/uploads route forwarded `await file.arrayBuffer()` (a raw ArrayBuffer) to heic-convert. heic-decode's isHeic() spreads the input (`String.fromCharCode(...buf.slice(8,12))`), which requires an iterable — an ArrayBuffer is not iterable, so HEIC uploads threw "Found non-callable @@iterator" and 500'd. The HEIC support added in PR #16 never actually worked on a real iPhone photo. Root contributor: @types/heic-convert mistypes `buffer` as ArrayBufferLike, which lured the route into passing the wrong shape. - Add heicToJpeg() helper that normalizes any binary input to an iterable Buffer (casting past the incorrect d.ts). - Route uses the helper. - Regression coverage via node:test + a real ~1KB HEIC fixture: Buffer, ArrayBuffer, and Uint8Array inputs all yield a JPEG. Adds tsx + `npm test`. Reproduced against a real HEIC; fix verified end-to-end (HEIC -> JPEG).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Four independent bug fixes batched into one branch since they all touch admin-flow UX. One commit per fix for easy review; GitHub will squash on merge.
Fixes
#135 — HEIC rejection on upload
src/app/api/uploads/route.tsalready allowedimage/heicandimage/heifMIME types, butsrc/lib/storage.tsuploadFile()re-validates against a stricter list that excludes them — so iPhone uploads threwFile type image/heic not allowed. Browsers (especially Chrome) also can't render HEIC inline, so even if storage accepted them, admins couldn't preview the doc photos.Fix: install pure-JS
heic-convert(no native deps, Railway-safe) and convert HEIC/HEIF → JPEG in the upload route before reachinguploadFile(). Storage's allow-list stays unchanged.#136 — Native
window.confirm()in approve/rejectsrc/app/admin/submissions/[id]/page.tsxusedwindow.confirm()for the destructive approve/reject prompts — jarring native browser dialog, inconsistent with the rest of the indigo/red Tailwind UI.Fix: built an inline
ConfirmDialogcomponent (overlay + centered card, autofocus, Escape-to-dismiss, red-tinted for reject / indigo for approve). Repo had no shared Dialog primitive, so it's local to the page — easy to extract tosrc/components/uilater when more pages need it.#137 — Approve silently fails when session expires
fetchWithAuthinsrc/contexts/AdminContext.tsxredirected to/admin/loginon 401 with no user-facing message. If an admin's session expired mid-form, clicking Approve → fetch → 401 → silent redirect made it look like "nothing happened".Fix:
AdminContext: showalert(\"Your session expired — please sign in again.\")before the redirect, gated by a module-level flag so concurrent 401s don't stack alerts.submission/[id]/page.tsx: added ashowMessage(text, tone)helper driving a red-tinted error banner. Non-OK responses now extract the server's{ error }body; thecatchsurfacesError.message. Same treatment applied to notes-save, resend, and PDF download (also bare-catch'd).#139 — SSN PDF field-not-found warning
Logs showed
I-9 PDF field not found: US Social Security Numberon every submission. Inspectedsrc/lib/i9-form.pdfwithpdf-lib— the bundled form (USCIS 01/20/25 edition) does have a field with exactly that name, and the existing default mapping insrc/lib/i9-field-mapping.tsis correct against it.Root cause must be an admin-uploaded PDF in production (via Admin → I-9 Form Management →
i9FormFileKey) using an older USCIS edition where the SSN field is namedSSNinstead.Fix: rather than guess a single replacement name (which would break the bundled form), made field resolution tolerant in
src/lib/i9pdf.ts:SSN,Social Security Number,U.S. Social Security Number,USSocialSecurityNumber.Default mapping unchanged. New PDF editions can be handled by adding to the alias table without breaking deployments still using older editions.
Note: I did NOT modify the SSN field name in the default mapping because the bundled
src/lib/i9-form.pdfshipped in the repo verifiably usesUS Social Security Number(confirmed viapdf-libgetFields()enumeration). Changing the default would break new deployments that haven't uploaded a custom form. If you can share the admin-uploaded PDF that's failing, I can add the exact field name as a direct entry. The fuzzy alias path should already cover it.Verification
npx tsc --noEmit— cleannpm run build— exits 0, all 35 pages prerender (Prisma config error during prerender is pre-existing local-only issue:.envhas SQLite URL butschema.prismadeclarespostgresqlprovider; harmless for the build itself)heic-converttypes pass + the dependency installed cleanly on macOS withlegacy-peer-depspdf-libenumeration locally against bundled form to confirm all 42 default-mapping entries resolveNotes for reviewer
alert()in AdminContext is intentional placeholder. Worth landing a proper toast primitive in a follow-up.ConfirmDialogis inlined in the page file. Move tosrc/components/ui/ConfirmDialog.tsxwhen a second page needs it.