Files
veola/templates/settings.templ
prosolis 7c95e4fd4e Add Authentik SSO, budget visibility, Resend email, 12h item default
Opens Veola up to more users (Parodia's Authentik) and makes Apify spend
visible to everyone.

- Auth: Traefik forward-auth (trust X-Authentik-* headers only from
  trusted_proxies CIDRs, keyed by email, role synced from admin_group),
  keeping local password login as break-glass. New [auth] config,
  CaptureDirectIP + ForwardAuth middleware, deploy/authentik-forward-auth.md.
- Budget: count every Apify run (apify_api_usage table) and show
  calls + estimated cost to all users on the dashboard, with an optional
  monthly-budget bar. New [budget] config + settings.
- Email: Resend client for opt-in deal alerts and a weekly digest
  (Mon 09:00). Per-user email + toggles on Settings. New [resend] config.
- Defaults: new items default to a 12-hour poll interval to cut spend.

users table gains email/auth_source/email-pref columns (migrated in place).
go build/vet/test green; boots and migrates cleanly.
2026-06-20 11:12:11 -07:00

300 lines
12 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
TestResendOK string
EbayUsedToday int
EbayDailyLimit int
ApifyToday int
ApifyMonth int
ApifyCostPerCall float64
MonthlyBudget float64
PasswordMsg string
PasswordError string
UserMsg string
UserError string
EmailMsg string
EmailError string
}
// ApifyMonthCost is the estimated month-to-date Apify spend.
func (d SettingsData) ApifyMonthCost() float64 {
return float64(d.ApifyMonth) * d.ApifyCostPerCall
}
// 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>
<div class="border-t border-white/10 pt-4">
<label class="v-label">Resend API Key</label>
<input class="v-input font-mono" type="password" name="resend_api_key" autocomplete="off" placeholder="re_... (leave blank to keep current value)"/>
@credStatus(d, "resend_api_key")
</div>
<div>
<label class="v-label">Resend From Address</label>
<input class="v-input" name="resend_from" value={ d.Values["resend_from"] } placeholder="Veola &lt;veola@yourdomain.com&gt;"/>
<div class="v-muted text-xs mt-1">Used for deal-alert and weekly-digest email. Domain must be verified in Resend.</div>
</div>
<div class="border-t border-white/10 pt-4">
<label class="v-label">Estimated Apify Cost per Call (USD)</label>
<input class="v-input font-mono" name="apify_cost_per_call" type="number" min="0" step="0.001" value={ d.Values["apify_cost_per_call"] }/>
<div class="v-muted text-xs mt-1">Apify does not report exact spend; this estimate drives the budget figures shown to everyone.</div>
</div>
<div>
<label class="v-label">Monthly Budget (USD)</label>
<input class="v-input font-mono" name="monthly_budget_usd" type="number" min="0" step="1" value={ d.Values["monthly_budget_usd"] }/>
<div class="v-muted text-xs mt-1">0 hides the budget bar. When set, the dashboard shows month-to-date spend against it.</div>
</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>
<button class="v-btn-ghost" type="submit" formaction="/settings/test-resend">Test Resend</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>
}
if d.TestResendOK != "" {
<div class="v-flash mt-3">{ d.TestResendOK }</div>
}
<div class="text-sm mt-4 border-t border-white/10 pt-4">
<span class="v-muted">Apify calls this month:</span>
<span class="font-mono">{ fmt.Sprintf("%d", d.ApifyMonth) }</span>
if d.ApifyCostPerCall > 0 {
<span class="v-muted ml-1">{ fmt.Sprintf("(~$%.2f est.)", d.ApifyMonthCost()) }</span>
}
<span class="v-muted ml-3">today:</span>
<span class="font-mono">{ fmt.Sprintf("%d", d.ApifyToday) }</span>
</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>
<section class="v-card p-6">
<h2 class="font-semibold mb-4">Email Notifications</h2>
if d.EmailError != "" {
<div class="v-flash-error">{ d.EmailError }</div>
}
if d.EmailMsg != "" {
<div class="v-flash">{ d.EmailMsg }</div>
}
<form method="post" action="/settings/email" class="space-y-4">
@CSRFInput(d.CSRFToken)
<div>
<label class="v-label">Notification Email</label>
<input class="v-input" type="email" name="email" value={ currentEmail(d) } placeholder="you@example.com"/>
<div class="v-muted text-xs mt-1">Where deal alerts and the weekly digest are sent. Requires Resend to be configured by an admin.</div>
</div>
<label class="flex items-center gap-2">
<input type="checkbox" name="email_deal_alerts" value="1" checked?={ currentDealAlerts(d) }/>
<span>Email me deal alerts</span>
</label>
<label class="flex items-center gap-2">
<input type="checkbox" name="email_weekly_digest" value="1" checked?={ currentWeeklyDigest(d) }/>
<span>Email me the weekly digest (Mondays)</span>
</label>
<button class="v-btn" type="submit">Save Preferences</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>
}
// currentEmail and the two opt-in accessors read the signed-in user's saved
// email preferences off Page.CurrentUser, guarding the nil case.
func currentEmail(d SettingsData) string {
if d.CurrentUser == nil {
return ""
}
return d.CurrentUser.Email
}
func currentDealAlerts(d SettingsData) bool {
return d.CurrentUser != nil && d.CurrentUser.EmailDealAlerts
}
func currentWeeklyDigest(d SettingsData) bool {
return d.CurrentUser != nil && d.CurrentUser.EmailWeeklyDigest
}
templ Settings(d SettingsData) {
@Layout(d.Page, settingsBody(d))
}