diff --git a/config.toml.example b/config.toml.example index 329cae9..803d70b 100644 --- a/config.toml.example +++ b/config.toml.example @@ -13,6 +13,25 @@ secure_cookies = true session_secret = "change-this-to-a-random-32-byte-string-aaaa" encryption_key = "change-this-to-a-different-random-32-byte-string-bb" +# Identity. mode = "local" (default) uses Veola's own password login. mode = +# "forward" additionally trusts identity headers set by a reverse proxy doing +# forward-auth (Traefik in front of Authentik); local password login stays +# available as a break-glass fallback. Users are matched/created by EMAIL. +# +# SECURITY: forward-auth headers are honored ONLY when the direct connection +# comes from a CIDR in trusted_proxies. Without this, anyone who can reach the +# port could spoof the headers. Set it to your proxy's address(es). Veola +# refuses to start in forward mode with an empty trusted_proxies list. +[auth] +mode = "local" +# trusted_proxies = ["127.0.0.1/32"] # required when mode = "forward" +# admin_group = "veola-admins" # IdP group that maps to the admin role +# Header names default to Authentik's; override only if your proxy differs. +# forward_header_user = "X-Authentik-Username" +# forward_header_email = "X-Authentik-Email" +# forward_header_name = "X-Authentik-Name" +# forward_header_groups = "X-Authentik-Groups" + [apify] api_key = "" @@ -55,6 +74,26 @@ daily_call_limit = 5000 base_url = "https://ntfy.yourdomain.com" default_topic = "veola" +# Resend transactional email (https://resend.com) for opt-in deal alerts and +# the weekly digest. api_key and from can also be set via /settings. The from +# address domain must be verified in your Resend account. Each user enables +# their own email + opt-ins on the Settings page. +[resend] +api_key = "" +from = "Veola " + +# Budget visibility. Apify bills per actor run/result, which Veola cannot read +# back exactly, so apify_cost_per_call is a USD estimate multiplied by the +# recorded run count. monthly_budget_usd > 0 draws a budget bar on the +# dashboard (visible to every signed-in user). Both are overridable via +# /settings. +[budget] +apify_cost_per_call = 0.0 +monthly_budget_usd = 0.0 + [scheduler] +# Per-item poll cadence is set on the add-item form (new items default to a +# 12-hour refresh to keep Apify spend down). This global value is the fallback +# for items left at 0. global_poll_interval_minutes = 60 match_confidence_threshold = 0.6 diff --git a/deploy/authentik-forward-auth.md b/deploy/authentik-forward-auth.md new file mode 100644 index 0000000..041b290 --- /dev/null +++ b/deploy/authentik-forward-auth.md @@ -0,0 +1,69 @@ +# Authentik forward-auth (via Traefik) + +Veola can delegate identity to Authentik using Traefik's `forwardAuth` +middleware. Veola itself does not speak OIDC; it trusts identity headers that +Authentik's proxy outpost sets, and matches/creates a local user by **email**. +Local password login stays available as a break-glass fallback. + +## 1. Veola config + +```toml +[auth] +mode = "forward" +trusted_proxies = ["172.18.0.0/16"] # CIDR(s) of the Traefik container/host +admin_group = "veola-admins" # Authentik group that grants admin +``` + +`trusted_proxies` is mandatory in forward mode: forward-auth headers are only +honored when the **direct** connection (before `X-Forwarded-For` rewriting) +comes from one of these CIDRs. Without it the headers are spoofable. Use the +address Traefik connects from (its Docker network range, or `127.0.0.1/32` if +co-located). + +Header names default to Authentik's (`X-Authentik-Username`, +`X-Authentik-Email`, `X-Authentik-Name`, `X-Authentik-Groups`); override under +`[auth]` only if your outpost differs. + +## 2. Authentik + +- Create a **Proxy Provider** in *forward auth (single application)* mode with + the external host (e.g. `https://veola.example.com`). +- Bind it to an Application, and bind the embedded/standalone outpost. +- Create a group (default name `veola-admins`) and add operators who should be + Veola admins. Everyone else lands as a regular user. Role is re-synced from + the group on every request. + +## 3. Traefik + +```yaml +http: + middlewares: + authentik: + forwardAuth: + address: "http://authentik-server:9000/outpost.goauthentik.io/auth/traefik" + trustForwardHeader: true + authResponseHeaders: + - X-authentik-username + - X-authentik-email + - X-authentik-name + - X-authentik-groups + + routers: + veola: + rule: "Host(`veola.example.com`)" + service: veola + middlewares: + - authentik + tls: + certResolver: letsencrypt +``` + +Traefik must strip any client-supplied `X-Authentik-*` and `X-Forwarded-*` +headers on ingress so only the outpost can set them. + +## Break-glass + +`/login` and `/setup` still work for local password accounts. If Authentik is +down, reach Veola directly (bypassing the proxy) from a trusted network and log +in with a local admin account. Forward-auth is a no-op for connections that do +not originate from `trusted_proxies`. diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 945bf9a..ca677e2 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -36,6 +36,9 @@ type Manager struct { Sessions *scs.SessionManager Store *db.Store hmacKey []byte + // forward, when non-nil, enables trust of reverse-proxy identity headers. + // See forward.go. + forward *ForwardAuthConfig } func NewManager(sqlDB *sql.DB, store *db.Store, sessionSecret string, secureCookies bool) (*Manager, error) { diff --git a/internal/auth/forward.go b/internal/auth/forward.go new file mode 100644 index 0000000..33d6158 --- /dev/null +++ b/internal/auth/forward.go @@ -0,0 +1,156 @@ +package auth + +import ( + "context" + "log/slog" + "net" + "net/http" + "strings" + + "veola/internal/models" +) + +// ForwardAuthConfig configures trust of reverse-proxy identity headers +// (Traefik forwardAuth in front of Authentik). It is nil unless forward-auth +// is enabled in config. +type ForwardAuthConfig struct { + HeaderUser string + HeaderEmail string + HeaderName string + HeaderGroups string + AdminGroup string + trustedNets []*net.IPNet +} + +// NewForwardAuthConfig parses the trusted-proxy CIDRs and returns a ready +// config. An entry that is a bare IP (no "/") is treated as a /32 or /128. +func NewForwardAuthConfig(headerUser, headerEmail, headerName, headerGroups, adminGroup string, trustedProxies []string) (*ForwardAuthConfig, error) { + fa := &ForwardAuthConfig{ + HeaderUser: headerUser, + HeaderEmail: headerEmail, + HeaderName: headerName, + HeaderGroups: headerGroups, + AdminGroup: adminGroup, + } + for _, raw := range trustedProxies { + raw = strings.TrimSpace(raw) + if raw == "" { + continue + } + if !strings.Contains(raw, "/") { + if strings.Contains(raw, ":") { + raw += "/128" + } else { + raw += "/32" + } + } + _, n, err := net.ParseCIDR(raw) + if err != nil { + return nil, err + } + fa.trustedNets = append(fa.trustedNets, n) + } + return fa, nil +} + +// trusts reports whether a direct-peer IP string is within a trusted CIDR. +func (fa *ForwardAuthConfig) trusts(ipStr string) bool { + ip := net.ParseIP(ipStr) + if ip == nil { + return false + } + for _, n := range fa.trustedNets { + if n.Contains(ip) { + return true + } + } + return false +} + +// SetForwardAuth enables forward-auth header trust on the manager. +func (m *Manager) SetForwardAuth(fa *ForwardAuthConfig) { m.forward = fa } + +type directIPCtxKey struct{} + +// CaptureDirectIP records the direct connection's remote IP into the request +// context BEFORE middleware.RealIP rewrites RemoteAddr from X-Forwarded-For. +// Forward-auth trust is checked against this captured value, not the +// proxy-supplied one, so a client cannot claim to be the proxy. +func CaptureDirectIP(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + host = r.RemoteAddr + } + ctx := context.WithValue(r.Context(), directIPCtxKey{}, host) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +func directIP(ctx context.Context) string { + s, _ := ctx.Value(directIPCtxKey{}).(string) + return s +} + +// ForwardAuth, when enabled, trusts identity headers from a configured proxy. +// It runs after LoadUser: if the direct peer is a trusted proxy and an email +// header is present, it find-or-creates the local user (keyed by email), +// re-syncs role from the IdP group, writes the user into the session + context, +// and so supersedes any stale local session. When forward-auth is disabled, or +// the peer is untrusted, or no headers are present, it is a no-op and local +// session auth applies (break-glass login still works). +func (m *Manager) ForwardAuth(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fa := m.forward + if fa == nil { + next.ServeHTTP(w, r) + return + } + if !fa.trusts(directIP(r.Context())) { + next.ServeHTTP(w, r) + return + } + email := strings.TrimSpace(r.Header.Get(fa.HeaderEmail)) + if email == "" { + next.ServeHTTP(w, r) + return + } + username := strings.TrimSpace(r.Header.Get(fa.HeaderUser)) + if username == "" { + username = strings.TrimSpace(r.Header.Get(fa.HeaderName)) + } + role := models.RoleUser + if fa.hasAdminGroup(r.Header.Get(fa.HeaderGroups)) { + role = models.RoleAdmin + } + u, err := m.Store.UpsertForwardUser(r.Context(), email, username, role) + if err != nil || u == nil { + slog.Error("forward-auth upsert failed", "email", email, "err", err) + http.Error(w, "forward-auth provisioning failed", http.StatusInternalServerError) + return + } + // Keep the session in sync so UserID()/CSRFToken() and a later + // non-proxied request behave; rotate only when the bound user changes. + if m.UserID(r.Context()) != u.ID { + if err := m.LogIn(r.Context(), u.ID); err != nil { + slog.Error("forward-auth session login failed", "err", err) + } + } + ctx := context.WithValue(r.Context(), ctxKeyUser, u) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +// hasAdminGroup reports whether the IdP groups header contains the admin group. +// Authentik joins groups with "|"; commas are also accepted. +func (fa *ForwardAuthConfig) hasAdminGroup(header string) bool { + if fa.AdminGroup == "" || header == "" { + return false + } + for _, g := range strings.FieldsFunc(header, func(r rune) bool { return r == '|' || r == ',' }) { + if strings.EqualFold(strings.TrimSpace(g), fa.AdminGroup) { + return true + } + } + return false +} diff --git a/internal/auth/forward_test.go b/internal/auth/forward_test.go new file mode 100644 index 0000000..157d280 --- /dev/null +++ b/internal/auth/forward_test.go @@ -0,0 +1,53 @@ +package auth + +import "testing" + +func TestForwardAuthTrusts(t *testing.T) { + fa, err := NewForwardAuthConfig("u", "e", "n", "g", "admins", []string{"127.0.0.1/32", "10.0.0.0/8", "::1"}) + if err != nil { + t.Fatalf("config: %v", err) + } + cases := map[string]bool{ + "127.0.0.1": true, + "10.5.6.7": true, + "::1": true, + "192.168.1.1": false, + "8.8.8.8": false, + "": false, + "not-an-ip": false, + } + for ip, want := range cases { + if got := fa.trusts(ip); got != want { + t.Errorf("trusts(%q) = %v, want %v", ip, got, want) + } + } +} + +func TestForwardAuthBadCIDR(t *testing.T) { + if _, err := NewForwardAuthConfig("u", "e", "n", "g", "a", []string{"not-a-cidr/99"}); err == nil { + t.Fatal("expected error for malformed CIDR") + } +} + +func TestHasAdminGroup(t *testing.T) { + fa := &ForwardAuthConfig{AdminGroup: "veola-admins"} + cases := map[string]bool{ + "veola-admins": true, + "users|veola-admins|staff": true, // Authentik pipe-delimited + "users,veola-admins": true, // comma-delimited + "VEOLA-ADMINS": true, // case-insensitive + "users|staff": false, + "": false, + "veola-admins-extra": false, + } + for header, want := range cases { + if got := fa.hasAdminGroup(header); got != want { + t.Errorf("hasAdminGroup(%q) = %v, want %v", header, got, want) + } + } + // Empty admin group never matches. + empty := &ForwardAuthConfig{AdminGroup: ""} + if empty.hasAdminGroup("anything") { + t.Error("empty admin group should not match") + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 7c2a6c9..2589c11 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "github.com/BurntSushi/toml" ) @@ -12,12 +13,53 @@ import ( type Config struct { Server ServerConfig `toml:"server"` Security SecurityConfig `toml:"security"` + Auth AuthConfig `toml:"auth"` Apify ApifyConfig `toml:"apify"` Ebay EbayConfig `toml:"ebay"` Ntfy NtfyConfig `toml:"ntfy"` + Resend ResendConfig `toml:"resend"` + Budget BudgetConfig `toml:"budget"` Scheduler SchedulerConfig `toml:"scheduler"` } +// AuthConfig controls how identities are established. Mode "local" (default) +// uses Veola's own password login. Mode "forward" trusts identity headers set +// by a reverse proxy doing forward-auth (Traefik in front of Authentik); local +// password login stays available as a break-glass fallback. The forward-auth +// headers are honored ONLY when the direct connection comes from a CIDR in +// TrustedProxies — without that gate anyone reaching the port could spoof them. +type AuthConfig struct { + Mode string `toml:"mode"` + ForwardHeaderUser string `toml:"forward_header_user"` + ForwardHeaderEmail string `toml:"forward_header_email"` + ForwardHeaderName string `toml:"forward_header_name"` + ForwardHeaderGroups string `toml:"forward_header_groups"` + AdminGroup string `toml:"admin_group"` + TrustedProxies []string `toml:"trusted_proxies"` +} + +// ForwardAuthEnabled reports whether forward-auth header trust is active. +func (c AuthConfig) ForwardAuthEnabled() bool { + return strings.EqualFold(strings.TrimSpace(c.Mode), "forward") +} + +// ResendConfig holds Resend transactional-email credentials. APIKey and From +// can both be overridden at runtime via /settings (resend_api_key, +// resend_from). From is an RFC-5322 address, e.g. "Veola ". +type ResendConfig struct { + APIKey string `toml:"api_key"` + From string `toml:"from"` +} + +// BudgetConfig drives the cost estimate shown to every user. CostPerApifyCall +// is a USD estimate multiplied by the recorded Apify run count (Apify does not +// report exact spend back to Veola). MonthlyBudgetUSD, when > 0, draws a +// budget-consumed bar on the dashboard. Both are overridable via /settings. +type BudgetConfig struct { + CostPerApifyCall float64 `toml:"apify_cost_per_call"` + MonthlyBudgetUSD float64 `toml:"monthly_budget_usd"` +} + // EbayConfig holds credentials for eBay's official Buy > Browse API. When set, // eBay marketplaces are polled through the Browse API instead of an Apify // scraper actor. ClientID is the App ID and ClientSecret is the Cert ID from @@ -144,6 +186,26 @@ func (c *Config) validate() error { if c.Ebay.DailyCallLimit == 0 { c.Ebay.DailyCallLimit = 5000 } + // Default the forward-auth header names to Authentik's defaults so a + // minimal [auth] block (just mode + trusted_proxies) works out of the box. + if c.Auth.ForwardHeaderUser == "" { + c.Auth.ForwardHeaderUser = "X-Authentik-Username" + } + if c.Auth.ForwardHeaderEmail == "" { + c.Auth.ForwardHeaderEmail = "X-Authentik-Email" + } + if c.Auth.ForwardHeaderName == "" { + c.Auth.ForwardHeaderName = "X-Authentik-Name" + } + if c.Auth.ForwardHeaderGroups == "" { + c.Auth.ForwardHeaderGroups = "X-Authentik-Groups" + } + if c.Auth.AdminGroup == "" { + c.Auth.AdminGroup = "veola-admins" + } + if c.Auth.ForwardAuthEnabled() && len(c.Auth.TrustedProxies) == 0 { + return errors.New("auth.mode is \"forward\" but auth.trusted_proxies is empty: forward-auth headers would be spoofable. Set the CIDR(s) of your reverse proxy (e.g. [\"127.0.0.1/32\"])") + } return nil } diff --git a/internal/db/db.go b/internal/db/db.go index 23806b2..6ff1fbb 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -53,6 +53,27 @@ func Open(path string) (*sql.DB, error) { conn.Close() return nil, err } + // Multi-user / email columns (Authentik forward-auth + Resend mail). + for _, c := range []struct{ col, typ string }{ + {"email", "TEXT"}, + {"auth_source", "TEXT NOT NULL DEFAULT 'local'"}, + {"email_deal_alerts", "INTEGER NOT NULL DEFAULT 0"}, + {"email_weekly_digest", "INTEGER NOT NULL DEFAULT 0"}, + } { + if err := addColumnIfMissing(conn, "users", c.col, c.typ); err != nil { + conn.Close() + return nil, err + } + } + // Email is the identity key for forward-auth (Authentik) and the Resend + // destination. Partial unique index: many local users may have no email + // (NULL/'') while any populated address stays unique. Created here, after + // the email column exists, so it works on both fresh and migrated DBs. + if _, err := conn.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email + ON users(email) WHERE email IS NOT NULL AND email != ''`); err != nil { + conn.Close() + return nil, fmt.Errorf("create users email index: %w", err) + } return conn, nil } diff --git a/internal/db/queries.go b/internal/db/queries.go index dd60229..d84f06f 100644 --- a/internal/db/queries.go +++ b/internal/db/queries.go @@ -94,6 +94,28 @@ func (s *Store) UserCount(ctx context.Context) (int, error) { return n, err } +const userSelect = `SELECT id, username, password_hash, role, email, auth_source, + email_deal_alerts, email_weekly_digest, created_at FROM users` + +func scanUser(r rowScanner) (*models.User, error) { + var ( + u models.User + role, authSource string + email sql.NullString + dealAlerts, digest int + ) + if err := r.Scan(&u.ID, &u.Username, &u.PasswordHash, &role, &email, &authSource, + &dealAlerts, &digest, &u.CreatedAt); err != nil { + return nil, err + } + u.Role = models.Role(role) + u.Email = email.String + u.AuthSource = authSource + u.EmailDealAlerts = dealAlerts != 0 + u.EmailWeeklyDigest = digest != 0 + return &u, nil +} + func (s *Store) CreateUser(ctx context.Context, username, hash string, role models.Role) (int64, error) { res, err := s.DB.ExecContext(ctx, `INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)`, @@ -105,61 +127,136 @@ func (s *Store) CreateUser(ctx context.Context, username, hash string, role mode } func (s *Store) GetUserByUsername(ctx context.Context, username string) (*models.User, error) { - row := s.DB.QueryRowContext(ctx, - `SELECT id, username, password_hash, role, created_at FROM users WHERE username = ?`, - username) - var u models.User - var role string - if err := row.Scan(&u.ID, &u.Username, &u.PasswordHash, &role, &u.CreatedAt); err != nil { - if errors.Is(err, sql.ErrNoRows) { - return nil, nil - } - return nil, err + u, err := scanUser(s.DB.QueryRowContext(ctx, userSelect+` WHERE username = ?`, username)) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil } - u.Role = models.Role(role) - return &u, nil + return u, err } func (s *Store) GetUserByID(ctx context.Context, id int64) (*models.User, error) { - row := s.DB.QueryRowContext(ctx, - `SELECT id, username, password_hash, role, created_at FROM users WHERE id = ?`, id) - var u models.User - var role string - if err := row.Scan(&u.ID, &u.Username, &u.PasswordHash, &role, &u.CreatedAt); err != nil { - if errors.Is(err, sql.ErrNoRows) { - return nil, nil - } - return nil, err + u, err := scanUser(s.DB.QueryRowContext(ctx, userSelect+` WHERE id = ?`, id)) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil } - u.Role = models.Role(role) - return &u, nil + return u, err +} + +func (s *Store) GetUserByEmail(ctx context.Context, email string) (*models.User, error) { + if email == "" { + return nil, nil + } + u, err := scanUser(s.DB.QueryRowContext(ctx, userSelect+` WHERE email = ?`, email)) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return u, err } func (s *Store) ListUsers(ctx context.Context) ([]models.User, error) { - rows, err := s.DB.QueryContext(ctx, - `SELECT id, username, password_hash, role, created_at FROM users ORDER BY id`) + rows, err := s.DB.QueryContext(ctx, userSelect+` ORDER BY id`) if err != nil { return nil, err } defer rows.Close() var out []models.User for rows.Next() { - var u models.User - var role string - if err := rows.Scan(&u.ID, &u.Username, &u.PasswordHash, &role, &u.CreatedAt); err != nil { + u, err := scanUser(rows) + if err != nil { return nil, err } - u.Role = models.Role(role) - out = append(out, u) + out = append(out, *u) } return out, rows.Err() } +// UpsertForwardUser find-or-creates a user keyed by email for forward-auth +// (Authentik). On an existing row it re-syncs role + username so the IdP stays +// the source of truth. Returns the resolved user. New rows are marked +// auth_source='forward' and carry an unusable password hash (forward-auth +// users never log in with a local password). +func (s *Store) UpsertForwardUser(ctx context.Context, email, username string, role models.Role) (*models.User, error) { + if email == "" { + return nil, errors.New("forward-auth requires an email") + } + existing, err := s.GetUserByEmail(ctx, email) + if err != nil { + return nil, err + } + if existing != nil { + if existing.Role != role || (username != "" && existing.Username != username) { + newName := existing.Username + if username != "" { + newName = username + } + if _, err := s.DB.ExecContext(ctx, + `UPDATE users SET role = ?, username = ? WHERE id = ?`, + string(role), newName, existing.ID); err != nil { + return nil, err + } + existing.Role = role + existing.Username = newName + } + return existing, nil + } + if username == "" { + username = email + } + res, err := s.DB.ExecContext(ctx, + `INSERT INTO users (username, password_hash, role, email, auth_source) VALUES (?, ?, ?, ?, 'forward')`, + username, "!forward-auth", string(role), email) + if err != nil { + return nil, err + } + id, err := res.LastInsertId() + if err != nil { + return nil, err + } + return s.GetUserByID(ctx, id) +} + func (s *Store) UpdateUserPassword(ctx context.Context, id int64, hash string) error { _, err := s.DB.ExecContext(ctx, `UPDATE users SET password_hash = ? WHERE id = ?`, hash, id) return err } +// UpdateUserEmailPrefs sets a user's notification email and per-channel email +// opt-ins. Email "" clears the address (and silences email for that user). +func (s *Store) UpdateUserEmailPrefs(ctx context.Context, id int64, email string, dealAlerts, weeklyDigest bool) error { + _, err := s.DB.ExecContext(ctx, + `UPDATE users SET email = ?, email_deal_alerts = ?, email_weekly_digest = ? WHERE id = ?`, + nullStr(email), boolToInt(dealAlerts), boolToInt(weeklyDigest), id) + return err +} + +// UsersWithDealAlerts returns users who opted into deal-alert email and have a +// destination address. UsersWithWeeklyDigest is the digest equivalent. +func (s *Store) UsersWithDealAlerts(ctx context.Context) ([]models.User, error) { + return s.usersWhereEmailPref(ctx, "email_deal_alerts") +} + +func (s *Store) UsersWithWeeklyDigest(ctx context.Context) ([]models.User, error) { + return s.usersWhereEmailPref(ctx, "email_weekly_digest") +} + +func (s *Store) usersWhereEmailPref(ctx context.Context, col string) ([]models.User, error) { + rows, err := s.DB.QueryContext(ctx, + userSelect+` WHERE `+col+` = 1 AND email IS NOT NULL AND email != '' ORDER BY id`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []models.User + for rows.Next() { + u, err := scanUser(rows) + if err != nil { + return nil, err + } + out = append(out, *u) + } + return out, rows.Err() +} + func (s *Store) DeleteUser(ctx context.Context, id int64) error { _, err := s.DB.ExecContext(ctx, `DELETE FROM users WHERE id = ?`, id) return err @@ -267,6 +364,47 @@ func (s *Store) IncrementEbayUsage(ctx context.Context) (int, error) { return n, nil } +// ============ apify api usage ============ + +// apifyUsageDay buckets Apify runs by UTC calendar day. Unlike eBay (which has +// its own Pacific-time quota reset) Apify billing is a soft estimate here, so a +// plain UTC day keeps the month math simple. +func apifyUsageDay() string { + return time.Now().UTC().Format("2006-01-02") +} + +// IncrementApifyUsage records one Apify actor run against the current UTC day. +func (s *Store) IncrementApifyUsage(ctx context.Context) error { + _, err := s.DB.ExecContext(ctx, ` + INSERT INTO apify_api_usage (usage_date, call_count) VALUES (?, 1) + ON CONFLICT(usage_date) DO UPDATE SET call_count = call_count + 1 + `, apifyUsageDay()) + return err +} + +// ApifyUsageToday returns Apify runs recorded for the current UTC day. +func (s *Store) ApifyUsageToday(ctx context.Context) (int, error) { + var n int + err := s.DB.QueryRowContext(ctx, + `SELECT call_count FROM apify_api_usage WHERE usage_date = ?`, apifyUsageDay()).Scan(&n) + if errors.Is(err, sql.ErrNoRows) { + return 0, nil + } + return n, err +} + +// ApifyUsageMonth returns total Apify runs in the current UTC calendar month. +func (s *Store) ApifyUsageMonth(ctx context.Context) (int, error) { + prefix := time.Now().UTC().Format("2006-01") + "-%" + var n sql.NullInt64 + err := s.DB.QueryRowContext(ctx, + `SELECT SUM(call_count) FROM apify_api_usage WHERE usage_date LIKE ?`, prefix).Scan(&n) + if err != nil { + return 0, err + } + return int(n.Int64), nil +} + // ============ items ============ func (s *Store) CreateItem(ctx context.Context, it *models.Item) (int64, error) { diff --git a/internal/db/schema.sql b/internal/db/schema.sql index e8d2cf9..8ef5ca1 100644 --- a/internal/db/schema.sql +++ b/internal/db/schema.sql @@ -6,8 +6,18 @@ CREATE TABLE IF NOT EXISTS users ( username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'user', + email TEXT, + -- auth_source records how the row was provisioned: 'local' (password + -- login / setup) or 'forward' (Traefik forward-auth from Authentik). For + -- forward rows the role is re-synced from the IdP group on every request. + auth_source TEXT NOT NULL DEFAULT 'local', + email_deal_alerts INTEGER NOT NULL DEFAULT 0, + email_weekly_digest INTEGER NOT NULL DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); +-- The email column and its unique index are created in db.go AFTER the +-- addColumnIfMissing migrations, so existing databases (whose users table +-- predates the email column) gain the column before the index references it. CREATE TABLE IF NOT EXISTS items ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -92,7 +102,20 @@ INSERT OR IGNORE INTO settings (key, value) VALUES ('ntfy_base_url', ''), ('ntfy_default_topic', 'veola'), ('global_poll_interval_minutes', '60'), - ('match_confidence_threshold', '0.6'); + ('match_confidence_threshold', '0.6'), + ('apify_cost_per_call', '0.00'), + ('monthly_budget_usd', '0.00'), + ('resend_api_key', ''), + ('resend_from', ''); + +-- apify_api_usage tracks Apify actor runs per UTC day so the operator (and +-- every signed-in user) can see consumption and an estimated spend. Apify +-- bills per actor run / per result, which Veola cannot read back exactly, so +-- this is a call counter multiplied by a configurable per-call cost estimate. +CREATE TABLE IF NOT EXISTS apify_api_usage ( + usage_date TEXT PRIMARY KEY, + call_count INTEGER NOT NULL DEFAULT 0 +); -- ebay_api_usage tracks Browse API calls per day so Veola can surface -- consumption and halt polling before the developer keyset's daily call diff --git a/internal/email/client.go b/internal/email/client.go new file mode 100644 index 0000000..38a2a84 --- /dev/null +++ b/internal/email/client.go @@ -0,0 +1,97 @@ +// Package email sends transactional mail through Resend (https://resend.com). +// Veola uses it for opt-in deal alerts and the weekly digest. Credentials are +// resolved at call time (settings table overriding config.toml), mirroring how +// the ntfy and Apify clients are built per-send. +package email + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +const apiURL = "https://api.resend.com/emails" + +// Client is a thin wrapper around the Resend send endpoint. +type Client struct { + APIKey string + From string + HTTP *http.Client +} + +// New returns a Resend client. apiKey and from are typically resolved from +// settings (falling back to config.toml) by the caller. +func New(apiKey, from string) *Client { + return &Client{ + APIKey: strings.TrimSpace(apiKey), + From: strings.TrimSpace(from), + HTTP: &http.Client{Timeout: 15 * time.Second}, + } +} + +// Configured reports whether the client has enough to attempt a send. +func (c *Client) Configured() bool { + return c.APIKey != "" && c.From != "" +} + +// Message is a single outbound email. HTML is the rendered body; Text is an +// optional plain-text alternative. +type Message struct { + To string + Subject string + HTML string + Text string +} + +type sendRequest struct { + From string `json:"from"` + To []string `json:"to"` + Subject string `json:"subject"` + HTML string `json:"html,omitempty"` + Text string `json:"text,omitempty"` +} + +// Send delivers one message. Returns an error if the client is unconfigured or +// Resend rejects the request. +func (c *Client) Send(ctx context.Context, m Message) error { + if c.APIKey == "" { + return fmt.Errorf("resend api key not configured") + } + if c.From == "" { + return fmt.Errorf("resend from address not configured") + } + if strings.TrimSpace(m.To) == "" { + return fmt.Errorf("recipient address required") + } + body, err := json.Marshal(sendRequest{ + From: c.From, + To: []string{m.To}, + Subject: m.Subject, + HTML: m.HTML, + Text: m.Text, + }) + if err != nil { + return err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+c.APIKey) + resp, err := c.HTTP.Do(req) + if err != nil { + return fmt.Errorf("resend POST: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode >= 300 { + b, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) + return fmt.Errorf("resend returned %d: %s", resp.StatusCode, string(b)) + } + return nil +} diff --git a/internal/handlers/dashboard.go b/internal/handlers/dashboard.go index c396eb1..7a610e6 100644 --- a/internal/handlers/dashboard.go +++ b/internal/handlers/dashboard.go @@ -63,11 +63,19 @@ func (a *App) dashboardData(r *http.Request) (templates.DashboardData, error) { if err != nil { return templates.DashboardData{}, err } + apifyToday, apifyMonth, costPerCall, monthlyBudget := a.Scheduler.ApifyUsage(r.Context()) + ebayUsed, ebayLimit := a.Scheduler.EbayUsage(r.Context()) return templates.DashboardData{ - Page: a.page(r, "Dashboard", "dashboard"), - Stats: stats, - RecentResults: rrs, - RecentAlerts: alerts, + Page: a.page(r, "Dashboard", "dashboard"), + Stats: stats, + RecentResults: rrs, + RecentAlerts: alerts, + ApifyToday: apifyToday, + ApifyMonth: apifyMonth, + ApifyCostPerCall: costPerCall, + MonthlyBudget: monthlyBudget, + EbayToday: ebayUsed, + EbayLimit: ebayLimit, }, nil } diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index c1b60f5..ac01abd 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -49,6 +49,10 @@ func (a *App) Routes() http.Handler { // RealIP rewrites RemoteAddr from X-Forwarded-For — safe only because // Veola is expected to sit behind a trusted proxy (Traefik) that sets // it; Traefik must be configured to strip client-supplied values. + // CaptureDirectIP must run before RealIP: forward-auth trusts identity + // headers only from the real connecting peer, which RealIP would otherwise + // overwrite with the proxy-supplied X-Forwarded-For. + r.Use(auth.CaptureDirectIP) r.Use(middleware.RealIP) r.Use(middleware.Recoverer) r.Use(securityHeaders) @@ -70,6 +74,10 @@ func (a *App) Routes() http.Handler { r.Group(func(r chi.Router) { r.Use(a.Auth.Sessions.LoadAndSave) r.Use(a.Auth.LoadUser) + // ForwardAuth runs after LoadUser so a trusted proxy's identity headers + // supersede any stale local session. No-op when forward-auth is off or + // the peer is untrusted. + r.Use(a.Auth.ForwardAuth) r.Use(a.setupGate) // Public auth pages. @@ -106,6 +114,8 @@ func (a *App) Routes() http.Handler { r.With(a.Auth.CSRFProtect).Post("/settings/test-ntfy", a.PostTestNtfy) r.With(a.Auth.CSRFProtect).Post("/settings/test-apify", a.PostTestApify) r.With(a.Auth.CSRFProtect).Post("/settings/test-ebay", a.PostTestEbay) + r.With(a.Auth.CSRFProtect).Post("/settings/test-resend", a.PostTestResend) + r.With(a.Auth.CSRFProtect).Post("/settings/email", a.PostEmailPrefs) r.With(a.Auth.CSRFProtect, a.Auth.RequireAdmin).Post("/users", a.PostCreateUser) r.With(a.Auth.CSRFProtect, a.Auth.RequireAdmin).Post("/users/{id}/delete", a.PostDeleteUser) r.With(a.Auth.CSRFProtect, a.Auth.RequireAdmin).Post("/users/{id}/reset-password", a.PostResetPassword) diff --git a/internal/handlers/items.go b/internal/handlers/items.go index 79ef021..ee40bd2 100644 --- a/internal/handlers/items.go +++ b/internal/handlers/items.go @@ -16,6 +16,10 @@ import ( "veola/templates" ) +// defaultPollIntervalMinutes is the poll cadence pre-selected on the add-item +// form: 12 hours, chosen to keep Apify spend low by default. +const defaultPollIntervalMinutes = 720 + func (a *App) GetItems(w http.ResponseWriter, r *http.Request) { cat := r.URL.Query().Get("category") all, err := a.Store.ListItems(r.Context()) @@ -56,8 +60,11 @@ func (a *App) GetNewItem(w http.ResponseWriter, r *http.Request) { IsEdit: false, Categories: cats, Item: models.Item{ - NtfyPriority: "default", - PollIntervalMinutes: a.Cfg.Scheduler.GlobalPollIntervalMinutes, + NtfyPriority: "default", + // Default new items to a 12-hour refresh. Apify runs cost credits + // and most watched items do not move hour-to-hour, so a slow + // default keeps spend down; the operator can dial it tighter. + PollIntervalMinutes: defaultPollIntervalMinutes, Marketplaces: []string{"ebay.com"}, ListingType: "all", }, diff --git a/internal/handlers/settings.go b/internal/handlers/settings.go index 1ec8acb..376f3e4 100644 --- a/internal/handlers/settings.go +++ b/internal/handlers/settings.go @@ -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: "

Test email from Veola settings. Resend is configured correctly.

", + 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 { diff --git a/internal/models/models.go b/internal/models/models.go index f347d9e..b3fa11d 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -17,7 +17,15 @@ type User struct { Username string PasswordHash string Role Role - CreatedAt time.Time + // Email is the address Resend mail is sent to and the identity key used + // to match a forward-auth (Authentik) identity to a local row. Optional + // for password-login users. + Email string + // AuthSource is "local" (password) or "forward" (Traefik forward-auth). + AuthSource string + EmailDealAlerts bool + EmailWeeklyDigest bool + CreatedAt time.Time } type Item struct { diff --git a/internal/scheduler/email.go b/internal/scheduler/email.go new file mode 100644 index 0000000..6e38913 --- /dev/null +++ b/internal/scheduler/email.go @@ -0,0 +1,171 @@ +package scheduler + +import ( + "context" + "fmt" + "html" + "log/slog" + "strings" + + "veola/internal/apify" + "veola/internal/db" + "veola/internal/email" + "veola/internal/models" +) + +// emailClient builds a Resend client with credentials resolved from settings, +// falling back to config.toml. Returns a client even when unconfigured; callers +// check Configured() to skip silently. +func (s *Scheduler) emailClient(ctx context.Context) *email.Client { + apiKey := s.cfg.Resend.APIKey + if v, _ := s.store.GetSetting(ctx, "resend_api_key"); v != "" { + apiKey = v + } + from := s.cfg.Resend.From + if v, _ := s.store.GetSetting(ctx, "resend_from"); v != "" { + from = v + } + return email.New(apiKey, from) +} + +// sendDealEmails mails a deal alert to every user who opted into deal-alert +// email. Entirely best-effort: an unconfigured Resend account or a per-user +// send failure is logged and swallowed. +func (s *Scheduler) sendDealEmails(ctx context.Context, it models.Item, r apify.UnifiedResult) { + client := s.emailClient(ctx) + if !client.Configured() { + return + } + recipients, err := s.store.UsersWithDealAlerts(ctx) + if err != nil { + slog.Error("load deal-alert recipients failed", "err", err) + return + } + if len(recipients) == 0 { + return + } + subject := fmt.Sprintf("Veola deal: %s", it.Name) + price := fmt.Sprintf("%s%.2f", currencyPrefix(r.Currency), r.Price) + target := "" + if it.TargetPrice != nil { + target = fmt.Sprintf("%s%.2f", currencyPrefix(r.Currency), *it.TargetPrice) + } + body := dealEmailHTML(it.Name, r.Title, r.Store, price, target, r.URL) + text := dealEmailText(it.Name, r.Title, r.Store, price, target, r.URL) + for _, u := range recipients { + if err := client.Send(ctx, email.Message{ + To: u.Email, Subject: subject, HTML: body, Text: text, + }); err != nil { + slog.Error("deal email send failed", "to", u.Email, "err", err) + } + } +} + +// RunWeeklyDigest builds and mails the weekly digest to every opted-in user. +// Public so it can be triggered manually as well as on the cron schedule. +func (s *Scheduler) RunWeeklyDigest(ctx context.Context) { + client := s.emailClient(ctx) + if !client.Configured() { + return + } + recipients, err := s.store.UsersWithWeeklyDigest(ctx) + if err != nil { + slog.Error("load digest recipients failed", "err", err) + return + } + if len(recipients) == 0 { + return + } + items, err := s.store.ListActiveItems(ctx) + if err != nil { + slog.Error("digest item load failed", "err", err) + return + } + stats, err := s.store.GetDashboardStats(ctx) + if err != nil { + slog.Error("digest stats load failed", "err", err) + return + } + subject := fmt.Sprintf("Veola weekly digest — %d items watched", len(items)) + body := digestHTML(items, stats) + text := digestText(items, stats) + for _, u := range recipients { + if err := client.Send(ctx, email.Message{ + To: u.Email, Subject: subject, HTML: body, Text: text, + }); err != nil { + slog.Error("digest email send failed", "to", u.Email, "err", err) + } + } + slog.Info("weekly digest sent", "recipients", len(recipients), "items", len(items)) +} + +func dealEmailHTML(itemName, title, store, price, target, url string) string { + var b strings.Builder + fmt.Fprintf(&b, `
`) + fmt.Fprintf(&b, `

Veola deal: %s

`, html.EscapeString(itemName)) + fmt.Fprintf(&b, `

%s at %s

`, html.EscapeString(price), html.EscapeString(store)) + if target != "" { + fmt.Fprintf(&b, `

Your target: %s

`, html.EscapeString(target)) + } + if title != "" { + fmt.Fprintf(&b, `

%s

`, html.EscapeString(title)) + } + if url != "" { + fmt.Fprintf(&b, `

View listing

`, html.EscapeString(url)) + } + b.WriteString(`
`) + return b.String() +} + +func dealEmailText(itemName, title, store, price, target, url string) string { + var b strings.Builder + fmt.Fprintf(&b, "Veola deal: %s\n\n%s at %s\n", itemName, price, store) + if target != "" { + fmt.Fprintf(&b, "Your target: %s\n", target) + } + if title != "" { + fmt.Fprintf(&b, "%s\n", title) + } + if url != "" { + fmt.Fprintf(&b, "%s\n", url) + } + return b.String() +} + +func digestHTML(items []models.Item, stats *db.DashboardStats) string { + var b strings.Builder + b.WriteString(`
`) + b.WriteString(`

Veola weekly digest

`) + if stats != nil { + fmt.Fprintf(&b, `

%d active items. Estimated money saved so far: $%.2f.

`, + stats.ActiveItems, stats.MoneySaved) + } + b.WriteString(``) + b.WriteString(``) + for _, it := range items { + best := "—" + if it.BestPrice != nil { + best = fmt.Sprintf("%s%.2f", currencyPrefix(it.BestPriceCurrency), *it.BestPrice) + } + fmt.Fprintf(&b, ``, + html.EscapeString(it.Name), html.EscapeString(best), html.EscapeString(it.BestPriceStore)) + } + b.WriteString(`
ItemBest priceWhere
%s%s%s
`) + return b.String() +} + +func digestText(items []models.Item, stats *db.DashboardStats) string { + var b strings.Builder + b.WriteString("Veola weekly digest\n\n") + if stats != nil { + fmt.Fprintf(&b, "%d active items. Estimated money saved so far: $%.2f.\n\n", stats.ActiveItems, stats.MoneySaved) + } + for _, it := range items { + best := "no price yet" + if it.BestPrice != nil { + best = fmt.Sprintf("%s%.2f", currencyPrefix(it.BestPriceCurrency), *it.BestPrice) + } + fmt.Fprintf(&b, "- %s: %s %s\n", it.Name, best, it.BestPriceStore) + } + return b.String() +} diff --git a/internal/scheduler/scheduler.go b/internal/scheduler/scheduler.go index 3d74957..682720a 100644 --- a/internal/scheduler/scheduler.go +++ b/internal/scheduler/scheduler.go @@ -2,6 +2,7 @@ package scheduler import ( "context" + "encoding/json" "fmt" "log/slog" "strconv" @@ -64,6 +65,15 @@ func (s *Scheduler) Start(ctx context.Context) error { for _, it := range items { s.register(it) } + // Weekly digest: Monday 09:00 server-local. Best-effort inside + // RunWeeklyDigest (no-op when Resend is unconfigured or nobody opted in). + if _, err := s.cron.AddFunc("0 9 * * 1", func() { + ctx, cancel := context.WithTimeout(s.rootCtx, 5*time.Minute) + defer cancel() + s.RunWeeklyDigest(ctx) + }); err != nil { + slog.Error("weekly digest schedule failed", "err", err) + } s.cron.Start() slog.Info("scheduler started", "items", len(items)) return nil @@ -142,7 +152,6 @@ func (s *Scheduler) RunPoll(ctx context.Context, it models.Item) { s.recordError(ctx, it.ID, "no marketplaces configured for this item") return } - apifyClient := s.apifyClient(ctx) var results []apify.UnifiedResult var errs []string successes := 0 @@ -176,7 +185,7 @@ func (s *Scheduler) RunPoll(ctx context.Context, it models.Item) { pcQueries = []string{""} } for _, q := range pcQueries { - pcRaw, err := apifyClient.Run(ctx, pcID, apify.PriceComparisonInput{ + pcRaw, err := s.runApifyActor(ctx, pcID, apify.PriceComparisonInput{ Query: q, URL: it.URL, ProxyConfiguration: s.proxyConfig(), }) @@ -238,6 +247,9 @@ func (s *Scheduler) RunPoll(ctx context.Context, it models.Item) { alerted = true alertsSent++ } + // Email opted-in users in addition to ntfy. Best-effort: a mail + // failure must not block result storage or the next alert. + s.sendDealEmails(ctx, it, r) } price := r.Price _, err = s.store.InsertResult(ctx, &models.Result{ @@ -307,6 +319,37 @@ func (s *Scheduler) apifyClient(ctx context.Context) *apify.Client { return apify.New(key) } +// runApifyActor runs an Apify actor and records the call against the daily +// usage counter that feeds the budget view. Every Apify-billed run in Veola +// goes through here so the count stays honest. +func (s *Scheduler) runApifyActor(ctx context.Context, actorID string, input any) ([]json.RawMessage, error) { + if err := s.store.IncrementApifyUsage(ctx); err != nil { + slog.Error("apify usage increment failed", "err", err) + } + return s.apifyClient(ctx).Run(ctx, actorID, input) +} + +// ApifyUsage reports Apify run counts and the cost model used by the budget +// view. costPerCall and monthlyBudget come from settings, falling back to +// config.toml. +func (s *Scheduler) ApifyUsage(ctx context.Context) (today, month int, costPerCall, monthlyBudget float64) { + today, _ = s.store.ApifyUsageToday(ctx) + month, _ = s.store.ApifyUsageMonth(ctx) + costPerCall = s.cfg.Budget.CostPerApifyCall + if v, _ := s.store.GetSetting(ctx, "apify_cost_per_call"); v != "" { + if f, err := strconv.ParseFloat(strings.TrimSpace(v), 64); err == nil { + costPerCall = f + } + } + monthlyBudget = s.cfg.Budget.MonthlyBudgetUSD + if v, _ := s.store.GetSetting(ctx, "monthly_budget_usd"); v != "" { + if f, err := strconv.ParseFloat(strings.TrimSpace(v), 64); err == nil { + monthlyBudget = f + } + } + return today, month, costPerCall, monthlyBudget +} + // ebayClient returns the shared eBay client with credentials refreshed from // settings (falling back to config.toml). The client caches its OAuth token // in memory, so the same instance is reused across polls; credentials are @@ -382,7 +425,7 @@ func (s *Scheduler) ExecutePlan(ctx context.Context, p actorPlan) ([]apify.Unifi if p.actorID == "" { return nil, fmt.Errorf("no actor configured for %s", p.marketplace) } - raw, err := s.apifyClient(ctx).Run(ctx, p.actorID, p.input) + raw, err := s.runApifyActor(ctx, p.actorID, p.input) if err != nil { return nil, err } @@ -677,7 +720,7 @@ func (s *Scheduler) seedSoldHistoryFor(ctx context.Context, it models.Item, quer if actorID == "" { return } - raw, err := s.apifyClient(ctx).Run(ctx, actorID, apify.SoldListingInput{ + raw, err := s.runApifyActor(ctx, actorID, apify.SoldListingInput{ Query: query, Marketplace: marketplace, MaxResults: 50, DaysBack: 30, ProxyConfiguration: s.proxyConfig(), }) diff --git a/main.go b/main.go index 46f88d4..603ab84 100644 --- a/main.go +++ b/main.go @@ -68,6 +68,18 @@ func run(configPath string) error { if err != nil { return fmt.Errorf("auth manager: %w", err) } + if cfg.Auth.ForwardAuthEnabled() { + fa, err := auth.NewForwardAuthConfig( + cfg.Auth.ForwardHeaderUser, cfg.Auth.ForwardHeaderEmail, + cfg.Auth.ForwardHeaderName, cfg.Auth.ForwardHeaderGroups, + cfg.Auth.AdminGroup, cfg.Auth.TrustedProxies, + ) + if err != nil { + return fmt.Errorf("forward-auth config: %w", err) + } + authMgr.SetForwardAuth(fa) + slog.Info("forward-auth enabled", "trusted_proxies", cfg.Auth.TrustedProxies, "admin_group", cfg.Auth.AdminGroup) + } apifyClient := apify.New(cfg.Apify.APIKey) ntfyClient := ntfy.New(cfg.Ntfy.BaseURL) diff --git a/static/css/app.css b/static/css/app.css index f001fcf..1e3b73e 100644 --- a/static/css/app.css +++ b/static/css/app.css @@ -474,3 +474,18 @@ table.v-table tbody tr:hover td { background: rgba(0, 164, 228, 0.08); } .v-just-swapped { animation: none; } .v-countdown-critical { animation: none; } } + +/* Budget progress bar (dashboard). Styled cross-browser without inline styles + so it stays within the strict CSP (no style-src 'unsafe-inline'). */ +.v-progress { + height: 0.6rem; + border-radius: 9999px; + overflow: hidden; + -webkit-appearance: none; + appearance: none; + border: 1px solid var(--border); + background: var(--surface-2); +} +.v-progress::-webkit-progress-bar { background: var(--surface-2); } +.v-progress::-webkit-progress-value { background: var(--accent); } +.v-progress::-moz-progress-bar { background: var(--accent); } diff --git a/templates/dashboard.templ b/templates/dashboard.templ index b1fe640..3c63604 100644 --- a/templates/dashboard.templ +++ b/templates/dashboard.templ @@ -13,6 +13,39 @@ type DashboardData struct { Stats *db.DashboardStats RecentResults []ResultRow RecentAlerts []AlertRow + // Budget surface, shown to every signed-in user (Apify calls cost money). + ApifyToday int + ApifyMonth int + ApifyCostPerCall float64 + MonthlyBudget float64 + EbayToday int + EbayLimit int +} + +// ApifyMonthCost is the estimated month-to-date Apify spend in USD. +func (d DashboardData) ApifyMonthCost() float64 { + return float64(d.ApifyMonth) * d.ApifyCostPerCall +} + +// BudgetPercent is month-to-date spend as a percent of the monthly budget, +// clamped to [0,100]. Zero when no budget is set. +func (d DashboardData) BudgetPercent() int { + if d.MonthlyBudget <= 0 { + return 0 + } + p := int(d.ApifyMonthCost() / d.MonthlyBudget * 100) + if p < 0 { + return 0 + } + if p > 100 { + return 100 + } + return p +} + +// BudgetOver reports whether estimated spend has exceeded the monthly budget. +func (d DashboardData) BudgetOver() bool { + return d.MonthlyBudget > 0 && d.ApifyMonthCost() > d.MonthlyBudget } type ResultRow struct { @@ -63,6 +96,50 @@ templ DashboardBody(d DashboardData) {
across { fmt.Sprintf("%d", d.Stats.SavedItemCount) } items
+
+
+

API Usage and Budget

+ Apify runs cost credits. Visible to everyone. +
+
+
+
Apify (month)
+
{ fmt.Sprintf("%d", d.ApifyMonth) }
+ if d.ApifyCostPerCall > 0 { +
{ fmt.Sprintf("~$%.2f est.", d.ApifyMonthCost()) }
+ } +
+
+
Apify (today)
+
{ fmt.Sprintf("%d", d.ApifyToday) }
+
+
+
eBay (today)
+ if d.EbayLimit > 0 { +
{ fmt.Sprintf("%d / %d", d.EbayToday, d.EbayLimit) }
+ } else { +
{ fmt.Sprintf("%d", d.EbayToday) }
+ } +
+ if d.MonthlyBudget > 0 { +
+
Monthly Budget
+
{ fmt.Sprintf("$%.0f", d.MonthlyBudget) }
+
+ } +
+ if d.MonthlyBudget > 0 { +
+ +
+ { fmt.Sprintf("%d%% of budget used", d.BudgetPercent()) } + if d.BudgetOver() { + Over budget + } +
+
+ } +

Recent Results

diff --git a/templates/dashboard_templ.go b/templates/dashboard_templ.go index 6d0fe55..3b25e75 100644 --- a/templates/dashboard_templ.go +++ b/templates/dashboard_templ.go @@ -21,6 +21,39 @@ type DashboardData struct { Stats *db.DashboardStats RecentResults []ResultRow RecentAlerts []AlertRow + // Budget surface, shown to every signed-in user (Apify calls cost money). + ApifyToday int + ApifyMonth int + ApifyCostPerCall float64 + MonthlyBudget float64 + EbayToday int + EbayLimit int +} + +// ApifyMonthCost is the estimated month-to-date Apify spend in USD. +func (d DashboardData) ApifyMonthCost() float64 { + return float64(d.ApifyMonth) * d.ApifyCostPerCall +} + +// BudgetPercent is month-to-date spend as a percent of the monthly budget, +// clamped to [0,100]. Zero when no budget is set. +func (d DashboardData) BudgetPercent() int { + if d.MonthlyBudget <= 0 { + return 0 + } + p := int(d.ApifyMonthCost() / d.MonthlyBudget * 100) + if p < 0 { + return 0 + } + if p > 100 { + return 100 + } + return p +} + +// BudgetOver reports whether estimated spend has exceeded the monthly budget. +func (d DashboardData) BudgetOver() bool { + return d.MonthlyBudget > 0 && d.ApifyMonthCost() > d.MonthlyBudget } type ResultRow struct { @@ -95,7 +128,7 @@ func DashboardBody(d DashboardData) templ.Component { var templ_7745c5c3_Var2 string templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("$%.2f", d.Stats.PotentialSpend)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 54, Col: 100} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 87, Col: 100} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) if templ_7745c5c3_Err != nil { @@ -108,7 +141,7 @@ func DashboardBody(d DashboardData) templ.Component { var templ_7745c5c3_Var3 string templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", d.Stats.PricedItemCount)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 55, Col: 89} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 88, Col: 89} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) if templ_7745c5c3_Err != nil { @@ -126,7 +159,7 @@ func DashboardBody(d DashboardData) templ.Component { var templ_7745c5c3_Var4 string templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d items not yet priced.", d.Stats.UnpricedCount)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 57, Col: 103} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 90, Col: 103} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) if templ_7745c5c3_Err != nil { @@ -144,7 +177,7 @@ func DashboardBody(d DashboardData) templ.Component { var templ_7745c5c3_Var5 string templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("$%.2f", d.Stats.MoneySaved)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 62, Col: 109} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 95, Col: 109} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) if templ_7745c5c3_Err != nil { @@ -157,154 +190,313 @@ func DashboardBody(d DashboardData) templ.Component { var templ_7745c5c3_Var6 string templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", d.Stats.SavedItemCount)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 63, Col: 88} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 96, Col: 88} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, " items

Recent Results

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, " items

API Usage and Budget

Apify runs cost credits. Visible to everyone.
Apify (month)
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var7 string + templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", d.ApifyMonth)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 107, Col: 75} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if d.ApifyCostPerCall > 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var8 string + templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("~$%.2f est.", d.ApifyMonthCost())) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 109, Col: 88} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "
Apify (today)
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var9 string + templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", d.ApifyToday)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 114, Col: 75} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "
eBay (today)
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if d.EbayLimit > 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var10 string + templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d / %d", d.EbayToday, d.EbayLimit)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 119, Col: 93} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var11 string + templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", d.EbayToday)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 121, Col: 75} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if d.MonthlyBudget > 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "
Monthly Budget
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var12 string + templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("$%.0f", d.MonthlyBudget)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 127, Col: 82} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if d.MonthlyBudget > 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var14 string + templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d%% of budget used", d.BudgetPercent())) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 135, Col: 83} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if d.BudgetOver() { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "Over budget") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "

Recent Results

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if len(d.RecentResults) == 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "
No results yet.
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "
No results yet.
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "
ItemPriceSourceFound
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } for _, r := range d.RecentResults { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "
ItemPriceSourceFound
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var8 string - templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(r.ItemName) + var templ_7745c5c3_Var16 string + templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(r.ItemName) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 79, Col: 95} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 156, Col: 95} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var9 string - templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(fmtPrice(r.Price, r.Currency)) + var templ_7745c5c3_Var17 string + templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(fmtPrice(r.Price, r.Currency)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 80, Col: 62} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 157, Col: 62} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var10 string - templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(r.Source) + var templ_7745c5c3_Var18 string + templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(r.Source) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 81, Col: 23} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 158, Col: 23} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var11 string - templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(humanTime(r.FoundAt)) + var templ_7745c5c3_Var19 string + templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(humanTime(r.FoundAt)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 82, Col: 59} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 159, Col: 59} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "

Recent Alerts

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "

Recent Alerts

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if len(d.RecentAlerts) == 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "
No alerts sent yet.
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "
No alerts sent yet.
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "
    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "
      ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } for _, a := range d.RecentAlerts { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "
    • ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "
    • ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var12 string - templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(a.ItemName) + var templ_7745c5c3_Var20 string + templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(a.ItemName) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 97, Col: 26} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 174, Col: 26} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var13 string - templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(fmtPrice(a.Price, a.Currency)) + var templ_7745c5c3_Var21 string + templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(fmtPrice(a.Price, a.Currency)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 98, Col: 78} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 175, Col: 78} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "
    • ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "
    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -328,61 +520,61 @@ func statCard(label, value, sub string) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var14 := templ.GetChildren(ctx) - if templ_7745c5c3_Var14 == nil { - templ_7745c5c3_Var14 = templ.NopComponent + templ_7745c5c3_Var22 := templ.GetChildren(ctx) + if templ_7745c5c3_Var22 == nil { + templ_7745c5c3_Var22 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var15 string - templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(label) + var templ_7745c5c3_Var23 string + templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(label) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 110, Col: 62} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 187, Col: 62} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var16 string - templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(value) + var templ_7745c5c3_Var24 string + templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(value) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 111, Col: 59} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 188, Col: 59} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if sub != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var17 string - templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(sub) + var templ_7745c5c3_Var25 string + templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(sub) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 113, Col: 42} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 190, Col: 42} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -406,9 +598,9 @@ func Dashboard(d DashboardData) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var18 := templ.GetChildren(ctx) - if templ_7745c5c3_Var18 == nil { - templ_7745c5c3_Var18 = templ.NopComponent + templ_7745c5c3_Var26 := templ.GetChildren(ctx) + if templ_7745c5c3_Var26 == nil { + templ_7745c5c3_Var26 = templ.NopComponent } ctx = templ.ClearChildren(ctx) templ_7745c5c3_Err = Layout(d.Page, DashboardBody(d)).Render(ctx, templ_7745c5c3_Buffer) diff --git a/templates/settings.templ b/templates/settings.templ index 58cb7e5..8cefa87 100644 --- a/templates/settings.templ +++ b/templates/settings.templ @@ -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) { +
+ + + @credStatus(d, "resend_api_key") +
+
+ + +
Used for deal-alert and weekly-digest email. Domain must be verified in Resend.
+
+
+ + +
Apify does not report exact spend; this estimate drives the budget figures shown to everyone.
+
+
+ + +
0 hides the budget bar. When set, the dashboard shows month-to-date spend against it.
+
if !d.IsAdmin {
Read-only for non-admin users.
} else { @@ -109,6 +141,7 @@ templ settingsBody(d SettingsData) { + } @@ -121,6 +154,18 @@ templ settingsBody(d SettingsData) { if d.TestEbayOK != "" {
{ d.TestEbayOK }
} + if d.TestResendOK != "" { +
{ d.TestResendOK }
+ } +
+ Apify calls this month: + { fmt.Sprintf("%d", d.ApifyMonth) } + if d.ApifyCostPerCall > 0 { + { fmt.Sprintf("(~$%.2f est.)", d.ApifyMonthCost()) } + } + today: + { fmt.Sprintf("%d", d.ApifyToday) } +
@@ -149,6 +194,33 @@ templ settingsBody(d SettingsData) {
+
+

Email Notifications

+ if d.EmailError != "" { +
{ d.EmailError }
+ } + if d.EmailMsg != "" { +
{ d.EmailMsg }
+ } +
+ @CSRFInput(d.CSRFToken) +
+ + +
Where deal alerts and the weekly digest are sent. Requires Resend to be configured by an admin.
+
+ + + +
+
+ if d.IsAdmin {

Users

@@ -205,6 +277,23 @@ templ settingsBody(d SettingsData) { } +// 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)) } diff --git a/templates/settings_templ.go b/templates/settings_templ.go index b9dbdd6..e6f668f 100644 --- a/templates/settings_templ.go +++ b/templates/settings_templ.go @@ -26,12 +26,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 @@ -71,7 +83,7 @@ func credStatus(d SettingsData, key string) templ.Component { var templ_7745c5c3_Var2 string templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(s) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 40, Col: 14} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 52, Col: 14} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) if templ_7745c5c3_Err != nil { @@ -146,7 +158,7 @@ func settingsBody(d SettingsData) templ.Component { var templ_7745c5c3_Var4 string templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Values["ebay_daily_call_limit"]) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 70, Col: 122} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 82, Col: 122} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4) if templ_7745c5c3_Err != nil { @@ -164,7 +176,7 @@ func settingsBody(d SettingsData) templ.Component { var templ_7745c5c3_Var5 string templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d / %d", d.EbayUsedToday, d.EbayDailyLimit)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 75, Col: 89} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 87, Col: 89} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) if templ_7745c5c3_Err != nil { @@ -182,7 +194,7 @@ func settingsBody(d SettingsData) templ.Component { var templ_7745c5c3_Var6 string templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d (uncapped)", d.EbayUsedToday)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 77, Col: 77} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 89, Col: 77} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) if templ_7745c5c3_Err != nil { @@ -206,7 +218,7 @@ func settingsBody(d SettingsData) templ.Component { var templ_7745c5c3_Var7 string templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Values["ntfy_base_url"]) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 85, Col: 82} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 97, Col: 82} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7) if templ_7745c5c3_Err != nil { @@ -219,7 +231,7 @@ func settingsBody(d SettingsData) templ.Component { var templ_7745c5c3_Var8 string templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Values["ntfy_default_topic"]) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 89, Col: 92} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 101, Col: 92} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8) if templ_7745c5c3_Err != nil { @@ -240,7 +252,7 @@ func settingsBody(d SettingsData) templ.Component { var templ_7745c5c3_Var9 string templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Values["global_poll_interval_minutes"]) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 98, Col: 136} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 110, Col: 136} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9) if templ_7745c5c3_Err != nil { @@ -253,80 +265,89 @@ func settingsBody(d SettingsData) templ.Component { var templ_7745c5c3_Var10 string templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Values["match_confidence_threshold"]) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 102, Col: 160} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 114, Col: 160} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\">") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\">
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = credStatus(d, "resend_api_key").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "
Used for deal-alert and weekly-digest email. Domain must be verified in Resend.
Apify does not report exact spend; this estimate drives the budget figures shown to everyone.
0 hides the budget bar. When set, the dashboard shows month-to-date spend against it.
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if !d.IsAdmin { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "
Read-only for non-admin users.
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "
Read-only for non-admin users.
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if d.TestNtfyOK != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var11 string - templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(d.TestNtfyOK) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 116, Col: 44} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - if d.TestApifyOK != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var12 string - templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(d.TestApifyOK) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 119, Col: 45} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - if d.TestEbayOK != "" { templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var13 string - templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(d.TestEbayOK) + var templ_7745c5c3_Var14 string + templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(d.TestNtfyOK) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 122, Col: 44} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 149, Col: 44} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -335,49 +356,155 @@ func settingsBody(d SettingsData) templ.Component { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "

Change Password

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - if d.PasswordError != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var14 string - templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(d.PasswordError) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 129, Col: 48} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - if d.PasswordMsg != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "
") + if d.TestApifyOK != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var15 string - templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(d.PasswordMsg) + templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(d.TestApifyOK) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 132, Col: 40} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 152, Col: 45} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "
") + if d.TestEbayOK != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var16 string + templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(d.TestEbayOK) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 155, Col: 44} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if d.TestResendOK != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var17 string + templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(d.TestResendOK) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 158, Col: 46} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "
Apify calls this month: ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var18 string + templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", d.ApifyMonth)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 162, Col: 61} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if d.ApifyCostPerCall > 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var19 string + templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("(~$%.2f est.)", d.ApifyMonthCost())) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 164, Col: 82} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "today: ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var20 string + templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", d.ApifyToday)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 167, Col: 61} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "

Change Password

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if d.PasswordError != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var21 string + templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(d.PasswordError) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 174, Col: 48} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if d.PasswordMsg != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var22 string + templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(d.PasswordMsg) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 177, Col: 40} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -385,173 +512,256 @@ func settingsBody(d SettingsData) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "

Email Notifications

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if d.EmailError != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var23 string + templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(d.EmailError) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 200, Col: 45} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if d.EmailMsg != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var24 string + templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(d.EmailMsg) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 203, Col: 37} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = CSRFInput(d.CSRFToken).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "
Where deal alerts and the weekly digest are sent. Requires Resend to be configured by an admin.
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if d.IsAdmin { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "

Users

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "

Users

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if d.UserError != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var16 string - templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(d.UserError) + var templ_7745c5c3_Var26 string + templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(d.UserError) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 156, Col: 45} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 228, Col: 45} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } if d.UserMsg != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var17 string - templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(d.UserMsg) + var templ_7745c5c3_Var27 string + templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(d.UserMsg) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 159, Col: 37} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 231, Col: 37} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "
UsernameRoleCreated
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } for _, u := range d.Users { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "\"> ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "
UsernameRoleCreated
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var18 string - templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(u.Username) + var templ_7745c5c3_Var28 string + templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(u.Username) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 166, Col: 24} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 238, Col: 24} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var19 string - templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(string(u.Role)) + var templ_7745c5c3_Var29 string + templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(string(u.Role)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 167, Col: 44} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 239, Col: 44} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var20 string - templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(u.CreatedAt.Format("2006-01-02")) + var templ_7745c5c3_Var30 string + templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(u.CreatedAt.Format("2006-01-02")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 168, Col: 70} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 240, Col: 70} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "\">
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -559,6 +769,23 @@ func settingsBody(d SettingsData) templ.Component { }) } +// 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 +} + func Settings(d SettingsData) templ.Component { return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context @@ -575,9 +802,9 @@ func Settings(d SettingsData) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var26 := templ.GetChildren(ctx) - if templ_7745c5c3_Var26 == nil { - templ_7745c5c3_Var26 = templ.NopComponent + templ_7745c5c3_Var36 := templ.GetChildren(ctx) + if templ_7745c5c3_Var36 == nil { + templ_7745c5c3_Var36 = templ.NopComponent } ctx = templ.ClearChildren(ctx) templ_7745c5c3_Err = Layout(d.Page, settingsBody(d)).Render(ctx, templ_7745c5c3_Buffer)