feat: single OTP field, event metadata on page, delete event

- OTP: 6 separate inputs → single 6-digit field with copy/paste support
- Event page: shows name, currency, exchange rate at top
- Edit toggle: unlock to edit fields, lock to save (via PUT)
- Delete button with confirmation popup
- DeleteEvent handler + DB function + route
- Backward compatible: collectOTP strips non-digits
This commit is contained in:
Claus Lohmar 2026-06-03 07:46:12 +00:00
parent 0748af7c26
commit bb34528bf5
6 changed files with 103 additions and 27 deletions

View file

@ -287,6 +287,16 @@ func UpdateEventStatus(db *sql.DB, id, status string) error {
return err return err
} }
// DeleteEvent removes an event and all its expenses from the database.
func DeleteEvent(db *sql.DB, id string) error {
_, err := db.Exec("DELETE FROM events WHERE id = ?", id)
if err != nil {
log.Printf("ERROR [%s] database: DeleteEvent(%s): %v",
time.Now().Format(time.RFC3339), id, err)
}
return err
}
// UpdateEvent updates the base currency and exchange rate of an existing event. // UpdateEvent updates the base currency and exchange rate of an existing event.
func UpdateEvent(db *sql.DB, id, baseCurrency string, exchangeRate float64) error { func UpdateEvent(db *sql.DB, id, baseCurrency string, exchangeRate float64) error {
_, err := db.Exec("UPDATE events SET base_currency = ?, exchange_rate = ? WHERE id = ?", baseCurrency, exchangeRate, id) _, err := db.Exec("UPDATE events SET base_currency = ?, exchange_rate = ? WHERE id = ?", baseCurrency, exchangeRate, id)

View file

@ -315,13 +315,16 @@ func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
} }
func collectOTP(r *http.Request) string { func collectOTP(r *http.Request) string {
var b strings.Builder code := r.FormValue("otp_code")
for i := 0; i < 6; i++ { // Strip any non-digit characters (paste may include spaces/dashes).
digit := r.FormValue(fmt.Sprintf("digit_%d", i)) code = strings.Map(func(r rune) rune {
if digit == "" { if r >= '0' && r <= '9' {
return r
}
return -1
}, code)
if len(code) != 6 {
return "" return ""
} }
b.WriteString(digit) return code
}
return b.String()
} }

View file

@ -360,3 +360,24 @@ func (h *EventHandler) UpdateEvent(w http.ResponseWriter, r *http.Request) {
w.Header().Set("HX-Redirect", "/dashboard") w.Header().Set("HX-Redirect", "/dashboard")
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
} }
// ---------------------------------------------------------------------------
// DELETE /events/{id} — DeleteEvent
// ---------------------------------------------------------------------------
// DeleteEvent removes an event and its expenses after ownership verification.
func (h *EventHandler) DeleteEvent(w http.ResponseWriter, r *http.Request) {
eventID := chi.URLParam(r, "id")
event, err := database.GetEventByID(h.DB, eventID)
if err != nil || event == nil || event.UserID != getUserID(r) {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
if err := database.DeleteEvent(h.DB, eventID); err != nil {
log.Printf("ERROR [%s] handlers: DeleteEvent(%s): %v", time.Now().Format(time.RFC3339), eventID, err)
http.Error(w, "Failed to delete event", http.StatusInternalServerError)
return
}
w.Header().Set("HX-Redirect", "/dashboard")
w.WriteHeader(http.StatusOK)
}

View file

@ -49,30 +49,15 @@ func loadTemplates() {
log.Printf("Loaded %d templates", len(templates)) log.Printf("Loaded %d templates", len(templates))
} }
// otpFormHTML is the inline OTP form template fragment with auto-advance. // otpFormHTML is the inline OTP form template fragment with single 6-digit field.
const otpFormHTML = ` const otpFormHTML = `
<form hx-post="/verify-otp" hx-target="#otp-form" hx-swap="innerHTML"> <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: #fca5a5; background: #450a0a; border: 1px solid #7f1d1d; padding: 0.75rem; border-radius: 0.5rem; margin-bottom: 1rem;">{{.Error}}</div>{{end}} {{if .Error}}<div class="error-message" style="color: #fca5a5; background: #450a0a; border: 1px solid #7f1d1d; 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 class="form-group" style="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="otp_code" inputmode="numeric" pattern="[0-9]{6}" maxlength="6" autocomplete="one-time-code" required
style="width: 3rem; height: 3rem; text-align: center; font-size: 1.5rem; border: 2px solid #475569; border-radius: 0.5rem; background: #1e293b; color: #f8fafc;" placeholder="Enter 6-digit code"
oninput="if(this.value)this.nextElementSibling.focus()" onkeydown="if(event.key==='Backspace'&&!this.value)this.previousElementSibling?.focus()"> style="width: 100%; padding: 1rem; font-size: 1.5rem; text-align: center; letter-spacing: 0.75rem; border: 2px solid #475569; border-radius: 0.5rem; background: #1e293b; color: #f8fafc; box-sizing: border-box;">
<input type="text" name="digit_1" maxlength="1" pattern="[0-9]" inputmode="numeric" required
style="width: 3rem; height: 3rem; text-align: center; font-size: 1.5rem; border: 2px solid #475569; border-radius: 0.5rem; background: #1e293b; color: #f8fafc;"
oninput="if(this.value)this.nextElementSibling.focus()" onkeydown="if(event.key==='Backspace'&&!this.value)this.previousElementSibling?.focus()">
<input type="text" name="digit_2" maxlength="1" pattern="[0-9]" inputmode="numeric" required
style="width: 3rem; height: 3rem; text-align: center; font-size: 1.5rem; border: 2px solid #475569; border-radius: 0.5rem; background: #1e293b; color: #f8fafc;"
oninput="if(this.value)this.nextElementSibling.focus()" onkeydown="if(event.key==='Backspace'&&!this.value)this.previousElementSibling?.focus()">
<input type="text" name="digit_3" maxlength="1" pattern="[0-9]" inputmode="numeric" required
style="width: 3rem; height: 3rem; text-align: center; font-size: 1.5rem; border: 2px solid #475569; border-radius: 0.5rem; background: #1e293b; color: #f8fafc;"
oninput="if(this.value)this.nextElementSibling.focus()" onkeydown="if(event.key==='Backspace'&&!this.value)this.previousElementSibling?.focus()">
<input type="text" name="digit_4" maxlength="1" pattern="[0-9]" inputmode="numeric" required
style="width: 3rem; height: 3rem; text-align: center; font-size: 1.5rem; border: 2px solid #475569; border-radius: 0.5rem; background: #1e293b; color: #f8fafc;"
oninput="if(this.value)this.nextElementSibling.focus()" onkeydown="if(event.key==='Backspace'&&!this.value)this.previousElementSibling?.focus()">
<input type="text" name="digit_5" maxlength="1" pattern="[0-9]" inputmode="numeric" required
style="width: 3rem; height: 3rem; text-align: center; font-size: 1.5rem; border: 2px solid #475569; border-radius: 0.5rem; background: #1e293b; color: #f8fafc;"
onkeydown="if(event.key==='Backspace'&&!this.value)this.previousElementSibling?.focus()">
</div> </div>
<button type="submit" class="btn btn-primary btn-block">Verify Code</button> <button type="submit" class="btn btn-primary btn-block">Verify Code</button>
</form>` </form>`

View file

@ -207,6 +207,7 @@ func main() {
r.Post("/events", eventHandler.CreateEvent) r.Post("/events", eventHandler.CreateEvent)
r.Put("/events/{id}", eventHandler.UpdateEvent) r.Put("/events/{id}", eventHandler.UpdateEvent)
r.Get("/events/{id}/edit", eventHandler.EditEvent) r.Get("/events/{id}/edit", eventHandler.EditEvent)
r.Delete("/events/{id}", eventHandler.DeleteEvent)
r.Put("/events/{id}/reopen", eventHandler.ReopenEvent) r.Put("/events/{id}/reopen", eventHandler.ReopenEvent)
r.Get("/events/{id}/expenses", eventHandler.ViewEventExpenses) r.Get("/events/{id}/expenses", eventHandler.ViewEventExpenses)

View file

@ -20,6 +20,62 @@
</header> </header>
<main class="main-content"> <main class="main-content">
<!-- Event Metadata -->
<div style="background: var(--color-card); border: 1px solid var(--color-border); border-radius: 0.5rem; padding: 1rem; margin-bottom: 1rem;">
<div style="display: flex; justify-content: space-between; align-items: flex-start;">
<div style="font-size: 0.75rem; color: var(--color-text-muted); text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 0.5rem;">Event Details</div>
<div style="display: flex; gap: 0.5rem;">
<button id="edit-toggle-btn" class="btn btn-secondary btn-sm" onclick="toggleEdit()" style="font-size: 0.75rem;">🔒 Edit</button>
<button class="btn btn-secondary btn-sm" style="font-size: 0.75rem; color: #fca5a5; border-color: #7f1d1d;"
onclick="if(confirm('Delete this event and all its receipts?')){hx.trigger('#delete-event','click')}">🗑️</button>
<div hx-delete="/events/{{.Event.ID}}" hx-target="body" hx-push-url="true" id="delete-event" style="display:none"></div>
</div>
</div>
<form id="event-meta-form" hx-put="/events/{{.Event.ID}}" hx-target="body" hx-push-url="true">
<div class="form-row">
<div class="form-group" style="flex:2">
<label class="form-label">Event</label>
<input type="text" name="name" value="{{.Event.Name}}" readonly
style="width:100%; padding:0.5rem; font-size:0.9rem; background:transparent; border:1px solid transparent; border-radius:0.375rem; color:var(--color-text);">
</div>
<div class="form-group" style="flex:1">
<label class="form-label">Claim Currency</label>
<input type="text" name="base_currency" value="{{.Event.BaseCurrency}}" readonly maxlength="3"
style="width:100%; padding:0.5rem; font-size:0.9rem; background:transparent; border:1px solid transparent; border-radius:0.375rem; color:var(--color-text); text-transform:uppercase;">
</div>
<div class="form-group" style="flex:1">
<label class="form-label">Rate</label>
<input type="text" name="exchange_rate" value="{{printf "%.6f" .Event.ExchangeRate}}" readonly
style="width:100%; padding:0.5rem; font-size:0.9rem; background:transparent; border:1px solid transparent; border-radius:0.375rem; color:var(--color-text-muted);">
</div>
</div>
</form>
</div>
<script>
function toggleEdit() {
var inputs = document.querySelectorAll('#event-meta-form input');
var btn = document.getElementById('edit-toggle-btn');
var isEditing = btn.textContent.includes('Unlock');
inputs.forEach(function(inp) {
inp.readOnly = isEditing;
if (isEditing) {
inp.style.background = 'transparent';
inp.style.borderColor = 'transparent';
inp.style.color = 'var(--color-text)';
} else {
inp.style.background = 'var(--color-card)';
inp.style.borderColor = 'var(--color-border)';
inp.style.color = 'var(--color-text)';
}
});
btn.textContent = isEditing ? '🔒 Edit' : '🔓 Save';
if (!isEditing) {
document.getElementById('event-meta-form').submit();
}
}
</script>
<!-- Receipt List --> <!-- Receipt List -->
<div id="expense-list"> <div id="expense-list">
<h3 style="margin-bottom: 1rem; font-size: 1rem; font-weight: 600;"> <h3 style="margin-bottom: 1rem; font-size: 1rem; font-weight: 600;">