fix: mobile receipt capture not working - missing name attribute on file input + htmx targetError on OTP verification

This commit is contained in:
Claus Lohmar 2026-05-30 11:34:36 +00:00
parent 6b6196a59a
commit 64f5b9a65b
3 changed files with 17 additions and 19 deletions

View file

@ -124,7 +124,7 @@ func (h *AuthHandler) RequestOTP(w http.ResponseWriter, r *http.Request) {
} }
// Render the OTP verification form as an HTMX fragment. // Render the OTP verification form as an HTMX fragment.
renderOTPForm(w, emailAddr) renderOTPForm(w, emailAddr, "")
} }
// VerifyOTP handles OTP code verification and session creation. // VerifyOTP handles OTP code verification and session creation.
@ -140,7 +140,7 @@ func (h *AuthHandler) VerifyOTP(w http.ResponseWriter, r *http.Request) {
otpCode := collectOTP(r) otpCode := collectOTP(r)
if emailAddr == "" || otpCode == "" { if emailAddr == "" || otpCode == "" {
renderError(w, "Email and OTP code are required.") renderOTPForm(w, emailAddr, "Email and OTP code are required.")
return return
} }
@ -148,11 +148,11 @@ func (h *AuthHandler) VerifyOTP(w http.ResponseWriter, r *http.Request) {
stored, err := database.GetOTP(h.DB, emailAddr) stored, err := database.GetOTP(h.DB, emailAddr)
if err != nil { if err != nil {
log.Printf("ERROR [%s] handlers: VerifyOTP GetOTP(%s): %v", time.Now().Format(time.RFC3339), emailAddr, err) log.Printf("ERROR [%s] handlers: VerifyOTP GetOTP(%s): %v", time.Now().Format(time.RFC3339), emailAddr, err)
renderError(w, "An error occurred. Please try again.") renderOTPForm(w, emailAddr, "An error occurred. Please try again.")
return return
} }
if stored == nil { if stored == nil {
renderError(w, "No OTP found for this email. Please request a new code.") renderOTPForm(w, emailAddr, "No OTP found for this email. Please request a new code.")
return return
} }
@ -160,14 +160,14 @@ func (h *AuthHandler) VerifyOTP(w http.ResponseWriter, r *http.Request) {
expiresAt, err := time.Parse(time.RFC3339, stored.ExpiresAt) expiresAt, err := time.Parse(time.RFC3339, stored.ExpiresAt)
if err != nil { if err != nil {
log.Printf("ERROR [%s] handlers: VerifyOTP parse expiry(%s): %v", time.Now().Format(time.RFC3339), stored.ExpiresAt, err) log.Printf("ERROR [%s] handlers: VerifyOTP parse expiry(%s): %v", time.Now().Format(time.RFC3339), stored.ExpiresAt, err)
renderError(w, "An error occurred. Please try again.") renderOTPForm(w, emailAddr, "An error occurred. Please try again.")
return return
} }
// Validate the OTP code and expiry. // Validate the OTP code and expiry.
if !auth.ValidateOTP(otpCode, stored.OTPCode, expiresAt) { if !auth.ValidateOTP(otpCode, stored.OTPCode, expiresAt) {
h.FailureTracker.RecordFailure(emailAddr) h.FailureTracker.RecordFailure(emailAddr)
renderError(w, "Invalid or expired OTP code. Please try again.") renderOTPForm(w, emailAddr, "Invalid or expired OTP code. Please try again.")
return return
} }
@ -182,7 +182,7 @@ func (h *AuthHandler) VerifyOTP(w http.ResponseWriter, r *http.Request) {
user, err := database.GetUserByEmail(h.DB, emailAddr) user, err := database.GetUserByEmail(h.DB, emailAddr)
if err != nil || user == nil { if err != nil || user == nil {
log.Printf("ERROR [%s] handlers: VerifyOTP GetUserByEmail(%s): err=%v", time.Now().Format(time.RFC3339), emailAddr, err) log.Printf("ERROR [%s] handlers: VerifyOTP GetUserByEmail(%s): err=%v", time.Now().Format(time.RFC3339), emailAddr, err)
renderError(w, "An error occurred. Please try again.") renderOTPForm(w, emailAddr, "An error occurred. Please try again.")
return return
} }
@ -190,7 +190,7 @@ func (h *AuthHandler) VerifyOTP(w http.ResponseWriter, r *http.Request) {
token, err := h.Sessions.Generate(user.ID) token, err := h.Sessions.Generate(user.ID)
if err != nil { if err != nil {
log.Printf("ERROR [%s] handlers: VerifyOTP Session Generate(%s): %v", time.Now().Format(time.RFC3339), user.ID, err) log.Printf("ERROR [%s] handlers: VerifyOTP Session Generate(%s): %v", time.Now().Format(time.RFC3339), user.ID, err)
renderError(w, "An error occurred. Please try again.") renderOTPForm(w, emailAddr, "An error occurred. Please try again.")
return return
} }
@ -257,10 +257,12 @@ func renderError(w http.ResponseWriter, message string) {
// renderOTPForm writes the OTP verification form partial as an HTMX fragment. // renderOTPForm writes the OTP verification form partial as an HTMX fragment.
// It renders 6 individual digit input boxes for a better mobile UX, plus a // It renders 6 individual digit input boxes for a better mobile UX, plus a
// hidden email field. The handler combines the 6 digits server-side. // hidden email field. The handler combines the 6 digits server-side.
func renderOTPForm(w http.ResponseWriter, email string) { // If errMsg is non-empty, it is displayed as an error banner above the form.
func renderOTPForm(w http.ResponseWriter, email string, errMsg string) {
tmpl := template.Must(template.New("otp_form").Parse(` tmpl := template.Must(template.New("otp_form").Parse(`
<form hx-post="/verify-otp" hx-target="#otp-form" hx-swap="outerHTML"> <form hx-post="/verify-otp" hx-target="#otp-form" hx-swap="innerHTML">
<input type="hidden" name="email" value="{{.Email}}"> <input type="hidden" name="email" value="{{.Email}}">
{{if .Error}}<div class="error-message" style="color: #dc2626; background: #fef2f2; border: 1px solid #fecaca; padding: 0.75rem; border-radius: 0.5rem; margin-bottom: 1rem;">{{.Error}}</div>{{end}}
<div style="display: flex; gap: 0.5rem; justify-content: center; margin: 1rem 0;"> <div style="display: flex; gap: 0.5rem; justify-content: center; margin: 1rem 0;">
<input type="text" name="digit_0" maxlength="1" pattern="[0-9]" inputmode="numeric" autocomplete="one-time-code" required <input type="text" name="digit_0" maxlength="1" pattern="[0-9]" inputmode="numeric" autocomplete="one-time-code" required
style="width: 3rem; height: 3rem; text-align: center; font-size: 1.5rem; border: 2px solid #d1d5db; border-radius: 0.5rem;"> style="width: 3rem; height: 3rem; text-align: center; font-size: 1.5rem; border: 2px solid #d1d5db; border-radius: 0.5rem;">
@ -282,7 +284,7 @@ func renderOTPForm(w http.ResponseWriter, email string) {
`)) `))
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := tmpl.Execute(w, map[string]string{"Email": email}); err != nil { if err := tmpl.Execute(w, map[string]string{"Email": email, "Error": errMsg}); err != nil {
log.Printf("ERROR [%s] handlers: renderOTPForm execute: %v", time.Now().Format(time.RFC3339), err) log.Printf("ERROR [%s] handlers: renderOTPForm execute: %v", time.Now().Format(time.RFC3339), err)
} }
} }

View file

@ -30,12 +30,13 @@
</svg> </svg>
Capture Receipt Capture Receipt
</label> </label>
<input type="file" id="receipt-upload" accept="image/*" capture="environment" style="display: none;" <input type="file" id="receipt-upload" name="receipt" accept="image/*" capture="environment" style="display: none;"
hx-post="/expenses/upload" hx-post="/expenses/upload"
hx-encoding="multipart/form-data" hx-encoding="multipart/form-data"
hx-target="#receipt-form" hx-target="#receipt-form"
hx-swap="innerHTML" hx-swap="innerHTML"
hx-indicator="#upload-indicator"> hx-indicator="#upload-indicator"
hx-trigger="change">
<div id="upload-indicator" class="htmx-indicator" style="text-align: center; padding: 1rem;"> <div id="upload-indicator" class="htmx-indicator" style="text-align: center; padding: 1rem;">
<div class="spinner"></div> <div class="spinner"></div>
<p>Analyzing receipt...</p> <p>Analyzing receipt...</p>
@ -130,11 +131,6 @@
</div> </div>
<script> <script>
// Auto-trigger file input on label click
document.querySelector('label[for="receipt-upload"]')?.addEventListener('click', function() {
document.getElementById('receipt-upload').click();
});
// Close modal on overlay click // Close modal on overlay click
document.querySelector('.modal-overlay')?.addEventListener('click', function(e) { document.querySelector('.modal-overlay')?.addEventListener('click', function(e) {
if (e.target === this) { if (e.target === this) {

View file

@ -26,7 +26,7 @@
</div> </div>
<div id="otp-form"> <div id="otp-form">
<form hx-post="/request-otp" hx-target="#otp-form" hx-swap="outerHTML"> <form hx-post="/request-otp" hx-target="#otp-form" hx-swap="innerHTML">
<div class="form-group"> <div class="form-group">
<label for="email">Email Address</label> <label for="email">Email Address</label>
<input type="email" id="email" name="email" placeholder="you@example.com" required autocomplete="email" inputmode="email"> <input type="email" id="email" name="email" placeholder="you@example.com" required autocomplete="email" inputmode="email">