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

@@ -9,6 +9,7 @@ import (
"veola/internal/apify"
"veola/internal/auth"
"veola/internal/ebay"
"veola/internal/email"
"veola/internal/models"
"veola/internal/ntfy"
"veola/templates"
@@ -24,6 +25,10 @@ var settingsKeys = []string{
"ntfy_token",
"global_poll_interval_minutes",
"match_confidence_threshold",
"apify_cost_per_call",
"monthly_budget_usd",
"resend_api_key",
"resend_from",
}
// secretSettingsKeys are credential fields. Their values are never rendered
@@ -34,6 +39,7 @@ var secretSettingsKeys = map[string]bool{
"ebay_client_id": true,
"ebay_client_secret": true,
"ntfy_token": true,
"resend_api_key": true,
}
// credentialStatus reports, per secret key, whether a value is saved in the
@@ -45,6 +51,7 @@ func (a *App) credentialStatus(values map[string]string) map[string]string {
"ebay_client_id": a.Cfg.Ebay.ClientID,
"ebay_client_secret": a.Cfg.Ebay.ClientSecret,
"ntfy_token": "",
"resend_api_key": a.Cfg.Resend.APIKey,
}
status := make(map[string]string, len(secretSettingsKeys))
for k := range secretSettingsKeys {
@@ -71,6 +78,7 @@ func (a *App) settingsData(r *http.Request) (templates.SettingsData, error) {
users, _ := a.Store.ListUsers(r.Context())
cur := auth.CurrentUserFromRequest(r)
ebayUsed, ebayLimit := a.Scheduler.EbayUsage(r.Context())
apifyToday, apifyMonth, costPerCall, monthlyBudget := a.Scheduler.ApifyUsage(r.Context())
return templates.SettingsData{
Page: a.page(r, "Settings", "settings"),
Values: values,
@@ -79,9 +87,103 @@ func (a *App) settingsData(r *http.Request) (templates.SettingsData, error) {
Users: users,
EbayUsedToday: ebayUsed,
EbayDailyLimit: ebayLimit,
ApifyToday: apifyToday,
ApifyMonth: apifyMonth,
ApifyCostPerCall: costPerCall,
MonthlyBudget: monthlyBudget,
}, nil
}
// PostEmailPrefs lets the signed-in user set their own notification email and
// opt into deal-alert / weekly-digest email. Available to every user, not just
// admins (each user owns their own delivery prefs).
func (a *App) PostEmailPrefs(w http.ResponseWriter, r *http.Request) {
cur := auth.CurrentUserFromRequest(r)
if cur == nil {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "bad form", http.StatusBadRequest)
return
}
email := strings.TrimSpace(r.PostFormValue("email"))
dealAlerts := r.PostFormValue("email_deal_alerts") == "1"
weeklyDigest := r.PostFormValue("email_weekly_digest") == "1"
d, err := a.settingsData(r)
if err != nil {
a.serverError(w, r, err)
return
}
if email == "" && (dealAlerts || weeklyDigest) {
d.EmailError = "Set an email address to receive email notifications."
render(w, r, templates.Settings(d))
return
}
if err := a.Store.UpdateUserEmailPrefs(r.Context(), cur.ID, email, dealAlerts, weeklyDigest); err != nil {
a.serverError(w, r, err)
return
}
// Reflect the saved values immediately (CurrentUser in d predates the write).
d, err = a.settingsData(r)
if err != nil {
a.serverError(w, r, err)
return
}
if d.Page.CurrentUser != nil {
d.Page.CurrentUser.Email = email
d.Page.CurrentUser.EmailDealAlerts = dealAlerts
d.Page.CurrentUser.EmailWeeklyDigest = weeklyDigest
}
d.EmailMsg = "Email preferences saved."
render(w, r, templates.Settings(d))
}
// PostTestResend sends a test email to the admin's own address to verify the
// Resend configuration.
func (a *App) PostTestResend(w http.ResponseWriter, r *http.Request) {
cur := auth.CurrentUserFromRequest(r)
if cur == nil || cur.Role != models.RoleAdmin {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
d, err := a.settingsData(r)
if err != nil {
a.serverError(w, r, err)
return
}
apiKey := strings.TrimSpace(d.Values["resend_api_key"])
if apiKey == "" {
apiKey = a.Cfg.Resend.APIKey
}
from := strings.TrimSpace(d.Values["resend_from"])
if from == "" {
from = a.Cfg.Resend.From
}
to := ""
if cur != nil {
to = cur.Email
}
if to == "" {
d.TestResendOK = "Set your notification email below first, then test."
render(w, r, templates.Settings(d))
return
}
client := email.New(apiKey, from)
if err := client.Send(r.Context(), email.Message{
To: to,
Subject: "Veola test email",
HTML: "<p>Test email from Veola settings. Resend is configured correctly.</p>",
Text: "Test email from Veola settings. Resend is configured correctly.",
}); err != nil {
d.TestResendOK = "Resend test failed: " + err.Error()
} else {
d.TestResendOK = "Sent a test email to " + to + "."
}
render(w, r, templates.Settings(d))
}
func (a *App) GetSettings(w http.ResponseWriter, r *http.Request) {
d, err := a.settingsData(r)
if err != nil {