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.
This commit is contained in:
prosolis
2026-06-20 11:12:11 -07:00
parent b6fadf6504
commit 7c95e4fd4e
23 changed files with 1883 additions and 261 deletions

View File

@@ -18,12 +18,24 @@ type SettingsData struct {
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
@@ -101,6 +113,26 @@ templ settingsBody(d SettingsData) {
<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 {
@@ -109,6 +141,7 @@ templ settingsBody(d SettingsData) {
<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>
@@ -121,6 +154,18 @@ templ settingsBody(d SettingsData) {
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">
@@ -149,6 +194,33 @@ templ settingsBody(d SettingsData) {
</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>
@@ -205,6 +277,23 @@ templ settingsBody(d SettingsData) {
</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))
}