Files
veola/templates/settings.templ
prosolis edb732ee1f Auction end times, visual flair, and pre-launch cleanup
Auction handling:
- Capture itemEndDate from eBay Browse API and ending_date from ZenMarket
  (Yahoo JP); plumb through results.ends_at column. Permissive ZenMarket
  parser (multiple layouts, JST when offset missing).
- Per-row "Ends" countdown column + "Ending soon" banner on results pages,
  live-ticked by flair.js with urgent/critical tinting under 1h/5m.
- Backfill ends_at for known auctions when their URL reappears in a poll
  (dedup hit no longer drops the new end time).
- Hide ended auctions from result listings by default via
  ResultsQuery.ExcludeEnded; rows stay in the DB.

Visual flair:
- Glassy backdrop-blur v-cards with gradient-mask borders and hover-lift.
- htmx swap fade-in via transient .v-just-swapped class.
- Count-up animation on dashboard stats. All animations gated behind
  prefers-reduced-motion.

eBay condition + region filters (auctions-style scoping):
- items.condition and items.region columns; threaded through item form,
  CreateItem/UpdateItem, scheduler eBay plan input, and previewKey so
  cache invalidates when these change.
- ebay.SearchParams gains conditionIds and itemLocationCountry filters.

Run Now reload + countdown engine:
- Run Now now sets HX-Refresh: true (non-htmx fallback: 303 redirect) so
  the entire results view — best price, chart, badge, last polled —
  reflects the new poll, instead of swapping just one partial.

Pre-launch hardening (P1 set):
- auth.EqualizeLoginTiming on no-such-user branch.
- (*App).serverError centralizes 500s; replaces err.Error() leaks across
  results/settings/items/users/dashboard handlers.
- main.go server: ReadTimeout 30s / WriteTimeout 60s / IdleTimeout 120s
  alongside the existing ReadHeaderTimeout.
- noListFS wrapper blocks static directory listings.
- Credential fields in settings no longer render value=; blank submission
  preserves the saved value, with per-field "Saved in settings / Set in
  config.toml / Not set" status indicator.

Misc:
- -debug flag wires slog to LevelDebug; raw ZenMarket items logged for
  format diagnosis.
- /healthz public endpoint for reverse-proxy probes.
- deploy/veola.service systemd unit template (hardening flags, single
  ReadWritePaths=/var/lib/veola).
- handlers_test.go covers /healthz, setup-gate redirect, auth gate, and
  /login render with httptest + in-memory sqlite.
- best_price_currency on items; templates pick the right symbol per row.
- .gitignore now excludes *.log / veola-debug.log.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 17:47:09 -07:00

211 lines
7.9 KiB
Plaintext

package templates
import (
"fmt"
"veola/internal/models"
)
type SettingsData struct {
Page
Values map[string]string
// CredentialStatus maps each secret settings key to a human-readable
// status ("Saved in settings", "Set in config.toml", "Not set"). Secret
// values are never rendered into the form itself.
CredentialStatus map[string]string
IsAdmin bool
Users []models.User
TestNtfyOK string
TestApifyOK string
TestEbayOK string
EbayUsedToday int
EbayDailyLimit int
PasswordMsg string
PasswordError string
UserMsg string
UserError string
}
// EbayLimitReached reports whether eBay polling is currently halted because
// the daily call limit has been hit.
func (d SettingsData) EbayLimitReached() bool {
return d.EbayDailyLimit > 0 && d.EbayUsedToday >= d.EbayDailyLimit
}
// credStatus renders the "Saved in settings / Set in config.toml / Not set"
// indicator for a secret field without ever printing the secret itself.
templ credStatus(d SettingsData, key string) {
if s := d.CredentialStatus[key]; s != "" {
<div class="v-muted text-xs mt-1">
Status: { s }
</div>
}
}
templ settingsBody(d SettingsData) {
<div class="space-y-8 max-w-3xl">
<h1 class="text-3xl font-semibold">Settings</h1>
<section class="v-card p-6">
<h2 class="font-semibold mb-4">Apify, eBay and Ntfy</h2>
<form method="post" action="/settings" class="space-y-4">
@CSRFInput(d.CSRFToken)
<div>
<label class="v-label">Apify API Key</label>
<input class="v-input font-mono" type="password" name="apify_api_key" autocomplete="off" placeholder="leave blank to keep current value"/>
@credStatus(d, "apify_api_key")
</div>
<div class="border-t border-white/10 pt-4">
<label class="v-label">eBay App ID (Client ID)</label>
<input class="v-input font-mono" type="password" name="ebay_client_id" autocomplete="off" placeholder="used for eBay marketplaces instead of Apify"/>
@credStatus(d, "ebay_client_id")
</div>
<div>
<label class="v-label">eBay Cert ID (Client Secret)</label>
<input class="v-input font-mono" type="password" name="ebay_client_secret" autocomplete="off" placeholder="leave blank to keep current value"/>
@credStatus(d, "ebay_client_secret")
</div>
<div>
<label class="v-label">eBay Daily Call Limit</label>
<input class="v-input font-mono" name="ebay_daily_call_limit" type="number" value={ d.Values["ebay_daily_call_limit"] } placeholder="5000 (blank uses config default)"/>
</div>
<div class="text-sm">
<span class="v-muted">eBay API calls today:</span>
if d.EbayDailyLimit > 0 {
<span class="font-mono">{ fmt.Sprintf("%d / %d", d.EbayUsedToday, d.EbayDailyLimit) }</span>
} else {
<span class="font-mono">{ fmt.Sprintf("%d (uncapped)", d.EbayUsedToday) }</span>
}
if d.EbayLimitReached() {
<span class="v-flash-error inline-block ml-2">Limit reached. eBay polling halted until the next reset (midnight US Pacific).</span>
}
</div>
<div class="border-t border-white/10 pt-4">
<label class="v-label">Ntfy Base URL</label>
<input class="v-input" name="ntfy_base_url" value={ d.Values["ntfy_base_url"] }/>
</div>
<div>
<label class="v-label">Ntfy Default Topic</label>
<input class="v-input" name="ntfy_default_topic" value={ d.Values["ntfy_default_topic"] }/>
</div>
<div>
<label class="v-label">Ntfy Token</label>
<input class="v-input font-mono" type="password" name="ntfy_token" autocomplete="off" placeholder="tk_... (leave blank to keep current value)"/>
@credStatus(d, "ntfy_token")
</div>
<div>
<label class="v-label">Global Poll Interval (minutes)</label>
<input class="v-input font-mono" name="global_poll_interval_minutes" type="number" value={ d.Values["global_poll_interval_minutes"] }/>
</div>
<div>
<label class="v-label">Match Confidence Threshold</label>
<input class="v-input font-mono" name="match_confidence_threshold" type="number" min="0" max="1" step="0.05" value={ d.Values["match_confidence_threshold"] }/>
</div>
if !d.IsAdmin {
<div class="v-muted text-sm">Read-only for non-admin users.</div>
} else {
<div class="flex items-center gap-3 pt-1">
<button class="v-btn" type="submit">Save</button>
<button class="v-btn-ghost" type="submit" formaction="/settings/test-ntfy">Test Ntfy</button>
<button class="v-btn-ghost" type="submit" formaction="/settings/test-apify">Test Apify</button>
<button class="v-btn-ghost" type="submit" formaction="/settings/test-ebay">Test eBay</button>
</div>
}
</form>
if d.TestNtfyOK != "" {
<div class="v-flash mt-3">{ d.TestNtfyOK }</div>
}
if d.TestApifyOK != "" {
<div class="v-flash mt-3">{ d.TestApifyOK }</div>
}
if d.TestEbayOK != "" {
<div class="v-flash mt-3">{ d.TestEbayOK }</div>
}
</section>
<section class="v-card p-6">
<h2 class="font-semibold mb-4">Change Password</h2>
if d.PasswordError != "" {
<div class="v-flash-error">{ d.PasswordError }</div>
}
if d.PasswordMsg != "" {
<div class="v-flash">{ d.PasswordMsg }</div>
}
<form method="post" action="/settings/password" class="space-y-4">
@CSRFInput(d.CSRFToken)
<div>
<label class="v-label">Current Password</label>
<input class="v-input" type="password" name="current_password"/>
</div>
<div>
<label class="v-label">New Password</label>
<input class="v-input" type="password" name="new_password"/>
</div>
<div>
<label class="v-label">Confirm New Password</label>
<input class="v-input" type="password" name="new_password_confirm"/>
</div>
<button class="v-btn" type="submit">Update Password</button>
</form>
</section>
if d.IsAdmin {
<section class="v-card p-6">
<h2 class="font-semibold mb-4">Users</h2>
if d.UserError != "" {
<div class="v-flash-error">{ d.UserError }</div>
}
if d.UserMsg != "" {
<div class="v-flash">{ d.UserMsg }</div>
}
<table class="v-table mb-4">
<thead><tr><th>Username</th><th>Role</th><th>Created</th><th></th></tr></thead>
<tbody>
for _, u := range d.Users {
<tr>
<td>{ u.Username }</td>
<td class="v-muted">{ string(u.Role) }</td>
<td class="v-muted text-sm">{ u.CreatedAt.Format("2006-01-02") }</td>
<td class="text-right">
<form class="inline" method="post" action={ templ.SafeURL(fmt.Sprintf("/users/%d/reset-password", u.ID)) }>
<input type="hidden" name="csrf_token" value={ d.CSRFToken }/>
<input type="password" class="v-input inline-block max-w-[140px]" name="new_password" placeholder="new password"/>
<button class="v-btn-ghost" type="submit">Reset</button>
</form>
<form class="inline" method="post" action={ templ.SafeURL(fmt.Sprintf("/users/%d/delete", u.ID)) } onsubmit="return confirm('Remove user?')">
<input type="hidden" name="csrf_token" value={ d.CSRFToken }/>
<button class="v-btn-ghost" type="submit">Remove</button>
</form>
</td>
</tr>
}
</tbody>
</table>
<form method="post" action="/users" class="grid md:grid-cols-4 gap-3 items-end">
<input type="hidden" name="csrf_token" value={ d.CSRFToken }/>
<div>
<label class="v-label">Username</label>
<input class="v-input" name="username"/>
</div>
<div>
<label class="v-label">Role</label>
<select class="v-select" name="role">
<option value="user">user</option>
<option value="admin">admin</option>
</select>
</div>
<div>
<label class="v-label">Initial Password</label>
<input class="v-input" type="password" name="password"/>
</div>
<button class="v-btn" type="submit">Add User</button>
</form>
</section>
}
</div>
}
templ Settings(d SettingsData) {
@Layout(d.Page, settingsBody(d))
}