Add Authentik SSO, budget visibility, Resend email, 12h item default
Opens Veola up to more users (Parodia's Authentik) and makes Apify spend visible to everyone. - Auth: Traefik forward-auth (trust X-Authentik-* headers only from trusted_proxies CIDRs, keyed by email, role synced from admin_group), keeping local password login as break-glass. New [auth] config, CaptureDirectIP + ForwardAuth middleware, deploy/authentik-forward-auth.md. - Budget: count every Apify run (apify_api_usage table) and show calls + estimated cost to all users on the dashboard, with an optional monthly-budget bar. New [budget] config + settings. - Email: Resend client for opt-in deal alerts and a weekly digest (Mon 09:00). Per-user email + toggles on Settings. New [resend] config. - Defaults: new items default to a 12-hour poll interval to cut spend. users table gains email/auth_source/email-pref columns (migrated in place). go build/vet/test green; boots and migrates cleanly.
This commit is contained in:
@@ -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 <veola@yourdomain.com>"
|
||||
|
||||
# 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
|
||||
|
||||
69
deploy/authentik-forward-auth.md
Normal file
69
deploy/authentik-forward-auth.md
Normal file
@@ -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`.
|
||||
@@ -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) {
|
||||
|
||||
156
internal/auth/forward.go
Normal file
156
internal/auth/forward.go
Normal file
@@ -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
|
||||
}
|
||||
53
internal/auth/forward_test.go
Normal file
53
internal/auth/forward_test.go
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 <veola@example.com>".
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
97
internal/email/client.go
Normal file
97
internal/email/client.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"veola/internal/apify"
|
||||
"veola/internal/auth"
|
||||
"veola/internal/ebay"
|
||||
"veola/internal/email"
|
||||
"veola/internal/models"
|
||||
"veola/internal/ntfy"
|
||||
"veola/templates"
|
||||
@@ -24,6 +25,10 @@ var settingsKeys = []string{
|
||||
"ntfy_token",
|
||||
"global_poll_interval_minutes",
|
||||
"match_confidence_threshold",
|
||||
"apify_cost_per_call",
|
||||
"monthly_budget_usd",
|
||||
"resend_api_key",
|
||||
"resend_from",
|
||||
}
|
||||
|
||||
// secretSettingsKeys are credential fields. Their values are never rendered
|
||||
@@ -34,6 +39,7 @@ var secretSettingsKeys = map[string]bool{
|
||||
"ebay_client_id": true,
|
||||
"ebay_client_secret": true,
|
||||
"ntfy_token": true,
|
||||
"resend_api_key": true,
|
||||
}
|
||||
|
||||
// credentialStatus reports, per secret key, whether a value is saved in the
|
||||
@@ -45,6 +51,7 @@ func (a *App) credentialStatus(values map[string]string) map[string]string {
|
||||
"ebay_client_id": a.Cfg.Ebay.ClientID,
|
||||
"ebay_client_secret": a.Cfg.Ebay.ClientSecret,
|
||||
"ntfy_token": "",
|
||||
"resend_api_key": a.Cfg.Resend.APIKey,
|
||||
}
|
||||
status := make(map[string]string, len(secretSettingsKeys))
|
||||
for k := range secretSettingsKeys {
|
||||
@@ -71,6 +78,7 @@ func (a *App) settingsData(r *http.Request) (templates.SettingsData, error) {
|
||||
users, _ := a.Store.ListUsers(r.Context())
|
||||
cur := auth.CurrentUserFromRequest(r)
|
||||
ebayUsed, ebayLimit := a.Scheduler.EbayUsage(r.Context())
|
||||
apifyToday, apifyMonth, costPerCall, monthlyBudget := a.Scheduler.ApifyUsage(r.Context())
|
||||
return templates.SettingsData{
|
||||
Page: a.page(r, "Settings", "settings"),
|
||||
Values: values,
|
||||
@@ -79,9 +87,103 @@ func (a *App) settingsData(r *http.Request) (templates.SettingsData, error) {
|
||||
Users: users,
|
||||
EbayUsedToday: ebayUsed,
|
||||
EbayDailyLimit: ebayLimit,
|
||||
ApifyToday: apifyToday,
|
||||
ApifyMonth: apifyMonth,
|
||||
ApifyCostPerCall: costPerCall,
|
||||
MonthlyBudget: monthlyBudget,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// PostEmailPrefs lets the signed-in user set their own notification email and
|
||||
// opt into deal-alert / weekly-digest email. Available to every user, not just
|
||||
// admins (each user owns their own delivery prefs).
|
||||
func (a *App) PostEmailPrefs(w http.ResponseWriter, r *http.Request) {
|
||||
cur := auth.CurrentUserFromRequest(r)
|
||||
if cur == nil {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
email := strings.TrimSpace(r.PostFormValue("email"))
|
||||
dealAlerts := r.PostFormValue("email_deal_alerts") == "1"
|
||||
weeklyDigest := r.PostFormValue("email_weekly_digest") == "1"
|
||||
|
||||
d, err := a.settingsData(r)
|
||||
if err != nil {
|
||||
a.serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
if email == "" && (dealAlerts || weeklyDigest) {
|
||||
d.EmailError = "Set an email address to receive email notifications."
|
||||
render(w, r, templates.Settings(d))
|
||||
return
|
||||
}
|
||||
if err := a.Store.UpdateUserEmailPrefs(r.Context(), cur.ID, email, dealAlerts, weeklyDigest); err != nil {
|
||||
a.serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
// Reflect the saved values immediately (CurrentUser in d predates the write).
|
||||
d, err = a.settingsData(r)
|
||||
if err != nil {
|
||||
a.serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
if d.Page.CurrentUser != nil {
|
||||
d.Page.CurrentUser.Email = email
|
||||
d.Page.CurrentUser.EmailDealAlerts = dealAlerts
|
||||
d.Page.CurrentUser.EmailWeeklyDigest = weeklyDigest
|
||||
}
|
||||
d.EmailMsg = "Email preferences saved."
|
||||
render(w, r, templates.Settings(d))
|
||||
}
|
||||
|
||||
// PostTestResend sends a test email to the admin's own address to verify the
|
||||
// Resend configuration.
|
||||
func (a *App) PostTestResend(w http.ResponseWriter, r *http.Request) {
|
||||
cur := auth.CurrentUserFromRequest(r)
|
||||
if cur == nil || cur.Role != models.RoleAdmin {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
d, err := a.settingsData(r)
|
||||
if err != nil {
|
||||
a.serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
apiKey := strings.TrimSpace(d.Values["resend_api_key"])
|
||||
if apiKey == "" {
|
||||
apiKey = a.Cfg.Resend.APIKey
|
||||
}
|
||||
from := strings.TrimSpace(d.Values["resend_from"])
|
||||
if from == "" {
|
||||
from = a.Cfg.Resend.From
|
||||
}
|
||||
to := ""
|
||||
if cur != nil {
|
||||
to = cur.Email
|
||||
}
|
||||
if to == "" {
|
||||
d.TestResendOK = "Set your notification email below first, then test."
|
||||
render(w, r, templates.Settings(d))
|
||||
return
|
||||
}
|
||||
client := email.New(apiKey, from)
|
||||
if err := client.Send(r.Context(), email.Message{
|
||||
To: to,
|
||||
Subject: "Veola test email",
|
||||
HTML: "<p>Test email from Veola settings. Resend is configured correctly.</p>",
|
||||
Text: "Test email from Veola settings. Resend is configured correctly.",
|
||||
}); err != nil {
|
||||
d.TestResendOK = "Resend test failed: " + err.Error()
|
||||
} else {
|
||||
d.TestResendOK = "Sent a test email to " + to + "."
|
||||
}
|
||||
render(w, r, templates.Settings(d))
|
||||
}
|
||||
|
||||
func (a *App) GetSettings(w http.ResponseWriter, r *http.Request) {
|
||||
d, err := a.settingsData(r)
|
||||
if err != nil {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
171
internal/scheduler/email.go
Normal file
171
internal/scheduler/email.go
Normal file
@@ -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, `<div style="font-family:sans-serif">`)
|
||||
fmt.Fprintf(&b, `<h2>Veola deal: %s</h2>`, html.EscapeString(itemName))
|
||||
fmt.Fprintf(&b, `<p style="font-size:20px"><strong>%s</strong> at %s</p>`, html.EscapeString(price), html.EscapeString(store))
|
||||
if target != "" {
|
||||
fmt.Fprintf(&b, `<p>Your target: %s</p>`, html.EscapeString(target))
|
||||
}
|
||||
if title != "" {
|
||||
fmt.Fprintf(&b, `<p>%s</p>`, html.EscapeString(title))
|
||||
}
|
||||
if url != "" {
|
||||
fmt.Fprintf(&b, `<p><a href="%s">View listing</a></p>`, html.EscapeString(url))
|
||||
}
|
||||
b.WriteString(`</div>`)
|
||||
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(`<div style="font-family:sans-serif">`)
|
||||
b.WriteString(`<h2>Veola weekly digest</h2>`)
|
||||
if stats != nil {
|
||||
fmt.Fprintf(&b, `<p>%d active items. Estimated money saved so far: $%.2f.</p>`,
|
||||
stats.ActiveItems, stats.MoneySaved)
|
||||
}
|
||||
b.WriteString(`<table style="border-collapse:collapse" cellpadding="6">`)
|
||||
b.WriteString(`<tr><th align="left">Item</th><th align="left">Best price</th><th align="left">Where</th></tr>`)
|
||||
for _, it := range items {
|
||||
best := "—"
|
||||
if it.BestPrice != nil {
|
||||
best = fmt.Sprintf("%s%.2f", currencyPrefix(it.BestPriceCurrency), *it.BestPrice)
|
||||
}
|
||||
fmt.Fprintf(&b, `<tr><td>%s</td><td>%s</td><td>%s</td></tr>`,
|
||||
html.EscapeString(it.Name), html.EscapeString(best), html.EscapeString(it.BestPriceStore))
|
||||
}
|
||||
b.WriteString(`</table></div>`)
|
||||
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()
|
||||
}
|
||||
@@ -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(),
|
||||
})
|
||||
|
||||
12
main.go
12
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)
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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) {
|
||||
<div class="v-muted text-sm mt-1">across { fmt.Sprintf("%d", d.Stats.SavedItemCount) } items</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="v-card p-5 mb-6">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h2 class="font-semibold">API Usage and Budget</h2>
|
||||
<span class="v-muted text-xs">Apify runs cost credits. Visible to everyone.</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<div class="v-muted text-xs uppercase tracking-wide">Apify (month)</div>
|
||||
<div class="font-mono text-2xl mt-1">{ fmt.Sprintf("%d", d.ApifyMonth) }</div>
|
||||
if d.ApifyCostPerCall > 0 {
|
||||
<div class="v-muted text-xs mt-1">{ fmt.Sprintf("~$%.2f est.", d.ApifyMonthCost()) }</div>
|
||||
}
|
||||
</div>
|
||||
<div>
|
||||
<div class="v-muted text-xs uppercase tracking-wide">Apify (today)</div>
|
||||
<div class="font-mono text-2xl mt-1">{ fmt.Sprintf("%d", d.ApifyToday) }</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="v-muted text-xs uppercase tracking-wide">eBay (today)</div>
|
||||
if d.EbayLimit > 0 {
|
||||
<div class="font-mono text-2xl mt-1">{ fmt.Sprintf("%d / %d", d.EbayToday, d.EbayLimit) }</div>
|
||||
} else {
|
||||
<div class="font-mono text-2xl mt-1">{ fmt.Sprintf("%d", d.EbayToday) }</div>
|
||||
}
|
||||
</div>
|
||||
if d.MonthlyBudget > 0 {
|
||||
<div>
|
||||
<div class="v-muted text-xs uppercase tracking-wide">Monthly Budget</div>
|
||||
<div class="font-mono text-2xl mt-1">{ fmt.Sprintf("$%.0f", d.MonthlyBudget) }</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
if d.MonthlyBudget > 0 {
|
||||
<div class="mt-4">
|
||||
<progress class="v-progress w-full" value={ fmt.Sprintf("%d", d.BudgetPercent()) } max="100"></progress>
|
||||
<div class="flex justify-between text-xs mt-1">
|
||||
<span class="v-muted">{ fmt.Sprintf("%d%% of budget used", d.BudgetPercent()) }</span>
|
||||
if d.BudgetOver() {
|
||||
<span class="v-price-target">Over budget</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="grid md:grid-cols-2 gap-6">
|
||||
<div class="v-card p-5">
|
||||
<h2 class="font-semibold mb-3">Recent Results</h2>
|
||||
|
||||
@@ -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</div></div></div><div class=\"grid md:grid-cols-2 gap-6\"><div class=\"v-card p-5\"><h2 class=\"font-semibold mb-3\">Recent Results</h2>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, " items</div></div></div><div class=\"v-card p-5 mb-6\"><div class=\"flex items-center justify-between mb-3\"><h2 class=\"font-semibold\">API Usage and Budget</h2><span class=\"v-muted text-xs\">Apify runs cost credits. Visible to everyone.</span></div><div class=\"grid grid-cols-2 md:grid-cols-4 gap-4\"><div><div class=\"v-muted text-xs uppercase tracking-wide\">Apify (month)</div><div class=\"font-mono text-2xl mt-1\">")
|
||||
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, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if d.ApifyCostPerCall > 0 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<div class=\"v-muted text-xs mt-1\">")
|
||||
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, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</div><div><div class=\"v-muted text-xs uppercase tracking-wide\">Apify (today)</div><div class=\"font-mono text-2xl mt-1\">")
|
||||
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, "</div></div><div><div class=\"v-muted text-xs uppercase tracking-wide\">eBay (today)</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if d.EbayLimit > 0 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<div class=\"font-mono text-2xl mt-1\">")
|
||||
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, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<div class=\"font-mono text-2xl mt-1\">")
|
||||
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, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if d.MonthlyBudget > 0 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<div><div class=\"v-muted text-xs uppercase tracking-wide\">Monthly Budget</div><div class=\"font-mono text-2xl mt-1\">")
|
||||
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, "</div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if d.MonthlyBudget > 0 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<div class=\"mt-4\"><progress class=\"v-progress w-full\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var13 string
|
||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%d", d.BudgetPercent()))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 133, Col: 85}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "\" max=\"100\"></progress><div class=\"flex justify-between text-xs mt-1\"><span class=\"v-muted\">")
|
||||
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, "</span> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if d.BudgetOver() {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<span class=\"v-price-target\">Over budget</span>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</div><div class=\"grid md:grid-cols-2 gap-6\"><div class=\"v-card p-5\"><h2 class=\"font-semibold mb-3\">Recent Results</h2>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if len(d.RecentResults) == 0 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<div class=\"v-muted text-sm\">No results yet.</div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<div class=\"v-muted text-sm\">No results yet.</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<table class=\"v-table\"><thead><tr><th>Item</th><th>Price</th><th>Source</th><th>Found</th></tr></thead> <tbody>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<table class=\"v-table\"><thead><tr><th>Item</th><th>Price</th><th>Source</th><th>Found</th></tr></thead> <tbody>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for _, r := range d.RecentResults {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<tr><td><a href=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<tr><td><a href=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var7 templ.SafeURL
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/items/%d/results", r.ItemID)))
|
||||
var templ_7745c5c3_Var15 templ.SafeURL
|
||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/items/%d/results", r.ItemID)))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 79, Col: 80}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 156, Col: 80}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||
_, 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, 13, "\">")
|
||||
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, "</a></td><td class=\"font-mono\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "</a></td><td class=\"font-mono\">")
|
||||
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, "</td><td>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</td><td>")
|
||||
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, "</td><td class=\"v-muted text-sm\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "</td><td class=\"v-muted text-sm\">")
|
||||
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, "</td></tr>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "</td></tr>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</tbody></table>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "</tbody></table>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</div><div class=\"v-card p-5\"><h2 class=\"font-semibold mb-3\">Recent Alerts</h2>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "</div><div class=\"v-card p-5\"><h2 class=\"font-semibold mb-3\">Recent Alerts</h2>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if len(d.RecentAlerts) == 0 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<div class=\"v-muted text-sm\">No alerts sent yet.</div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "<div class=\"v-muted text-sm\">No alerts sent yet.</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<ul class=\"space-y-2\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "<ul class=\"space-y-2\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for _, a := range d.RecentAlerts {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<li class=\"flex justify-between items-center border-b border-white/10 pb-2\"><span>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<li class=\"flex justify-between items-center border-b border-white/10 pb-2\"><span>")
|
||||
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, "</span> <span class=\"font-mono v-price-target\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</span> <span class=\"font-mono v-price-target\">")
|
||||
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, "</span></li>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "</span></li>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</ul>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "</ul>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</div></div></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "</div></div></div>")
|
||||
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, "<div class=\"v-card p-4\"><div class=\"v-muted text-xs uppercase tracking-wide\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "<div class=\"v-card p-4\"><div class=\"v-muted text-xs uppercase tracking-wide\">")
|
||||
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, "</div><div class=\"font-mono text-3xl mt-1\" data-countup>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "</div><div class=\"font-mono text-3xl mt-1\" data-countup>")
|
||||
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, "</div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if sub != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<div class=\"v-muted text-xs mt-1\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "<div class=\"v-muted text-xs mt-1\">")
|
||||
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, "</div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "</div>")
|
||||
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)
|
||||
|
||||
@@ -18,12 +18,24 @@ type SettingsData struct {
|
||||
TestNtfyOK string
|
||||
TestApifyOK string
|
||||
TestEbayOK string
|
||||
TestResendOK string
|
||||
EbayUsedToday int
|
||||
EbayDailyLimit int
|
||||
ApifyToday int
|
||||
ApifyMonth int
|
||||
ApifyCostPerCall float64
|
||||
MonthlyBudget float64
|
||||
PasswordMsg string
|
||||
PasswordError string
|
||||
UserMsg string
|
||||
UserError string
|
||||
EmailMsg string
|
||||
EmailError string
|
||||
}
|
||||
|
||||
// ApifyMonthCost is the estimated month-to-date Apify spend.
|
||||
func (d SettingsData) ApifyMonthCost() float64 {
|
||||
return float64(d.ApifyMonth) * d.ApifyCostPerCall
|
||||
}
|
||||
|
||||
// EbayLimitReached reports whether eBay polling is currently halted because
|
||||
@@ -101,6 +113,26 @@ templ settingsBody(d SettingsData) {
|
||||
<label class="v-label">Match Confidence Threshold</label>
|
||||
<input class="v-input font-mono" name="match_confidence_threshold" type="number" min="0" max="1" step="0.05" value={ d.Values["match_confidence_threshold"] }/>
|
||||
</div>
|
||||
<div class="border-t border-white/10 pt-4">
|
||||
<label class="v-label">Resend API Key</label>
|
||||
<input class="v-input font-mono" type="password" name="resend_api_key" autocomplete="off" placeholder="re_... (leave blank to keep current value)"/>
|
||||
@credStatus(d, "resend_api_key")
|
||||
</div>
|
||||
<div>
|
||||
<label class="v-label">Resend From Address</label>
|
||||
<input class="v-input" name="resend_from" value={ d.Values["resend_from"] } placeholder="Veola <veola@yourdomain.com>"/>
|
||||
<div class="v-muted text-xs mt-1">Used for deal-alert and weekly-digest email. Domain must be verified in Resend.</div>
|
||||
</div>
|
||||
<div class="border-t border-white/10 pt-4">
|
||||
<label class="v-label">Estimated Apify Cost per Call (USD)</label>
|
||||
<input class="v-input font-mono" name="apify_cost_per_call" type="number" min="0" step="0.001" value={ d.Values["apify_cost_per_call"] }/>
|
||||
<div class="v-muted text-xs mt-1">Apify does not report exact spend; this estimate drives the budget figures shown to everyone.</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="v-label">Monthly Budget (USD)</label>
|
||||
<input class="v-input font-mono" name="monthly_budget_usd" type="number" min="0" step="1" value={ d.Values["monthly_budget_usd"] }/>
|
||||
<div class="v-muted text-xs mt-1">0 hides the budget bar. When set, the dashboard shows month-to-date spend against it.</div>
|
||||
</div>
|
||||
if !d.IsAdmin {
|
||||
<div class="v-muted text-sm">Read-only for non-admin users.</div>
|
||||
} else {
|
||||
@@ -109,6 +141,7 @@ templ settingsBody(d SettingsData) {
|
||||
<button class="v-btn-ghost" type="submit" formaction="/settings/test-ntfy">Test Ntfy</button>
|
||||
<button class="v-btn-ghost" type="submit" formaction="/settings/test-apify">Test Apify</button>
|
||||
<button class="v-btn-ghost" type="submit" formaction="/settings/test-ebay">Test eBay</button>
|
||||
<button class="v-btn-ghost" type="submit" formaction="/settings/test-resend">Test Resend</button>
|
||||
</div>
|
||||
}
|
||||
</form>
|
||||
@@ -121,6 +154,18 @@ templ settingsBody(d SettingsData) {
|
||||
if d.TestEbayOK != "" {
|
||||
<div class="v-flash mt-3">{ d.TestEbayOK }</div>
|
||||
}
|
||||
if d.TestResendOK != "" {
|
||||
<div class="v-flash mt-3">{ d.TestResendOK }</div>
|
||||
}
|
||||
<div class="text-sm mt-4 border-t border-white/10 pt-4">
|
||||
<span class="v-muted">Apify calls this month:</span>
|
||||
<span class="font-mono">{ fmt.Sprintf("%d", d.ApifyMonth) }</span>
|
||||
if d.ApifyCostPerCall > 0 {
|
||||
<span class="v-muted ml-1">{ fmt.Sprintf("(~$%.2f est.)", d.ApifyMonthCost()) }</span>
|
||||
}
|
||||
<span class="v-muted ml-3">today:</span>
|
||||
<span class="font-mono">{ fmt.Sprintf("%d", d.ApifyToday) }</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="v-card p-6">
|
||||
@@ -149,6 +194,33 @@ templ settingsBody(d SettingsData) {
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="v-card p-6">
|
||||
<h2 class="font-semibold mb-4">Email Notifications</h2>
|
||||
if d.EmailError != "" {
|
||||
<div class="v-flash-error">{ d.EmailError }</div>
|
||||
}
|
||||
if d.EmailMsg != "" {
|
||||
<div class="v-flash">{ d.EmailMsg }</div>
|
||||
}
|
||||
<form method="post" action="/settings/email" class="space-y-4">
|
||||
@CSRFInput(d.CSRFToken)
|
||||
<div>
|
||||
<label class="v-label">Notification Email</label>
|
||||
<input class="v-input" type="email" name="email" value={ currentEmail(d) } placeholder="you@example.com"/>
|
||||
<div class="v-muted text-xs mt-1">Where deal alerts and the weekly digest are sent. Requires Resend to be configured by an admin.</div>
|
||||
</div>
|
||||
<label class="flex items-center gap-2">
|
||||
<input type="checkbox" name="email_deal_alerts" value="1" checked?={ currentDealAlerts(d) }/>
|
||||
<span>Email me deal alerts</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2">
|
||||
<input type="checkbox" name="email_weekly_digest" value="1" checked?={ currentWeeklyDigest(d) }/>
|
||||
<span>Email me the weekly digest (Mondays)</span>
|
||||
</label>
|
||||
<button class="v-btn" type="submit">Save Preferences</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
if d.IsAdmin {
|
||||
<section class="v-card p-6">
|
||||
<h2 class="font-semibold mb-4">Users</h2>
|
||||
@@ -205,6 +277,23 @@ templ settingsBody(d SettingsData) {
|
||||
</div>
|
||||
}
|
||||
|
||||
// currentEmail and the two opt-in accessors read the signed-in user's saved
|
||||
// email preferences off Page.CurrentUser, guarding the nil case.
|
||||
func currentEmail(d SettingsData) string {
|
||||
if d.CurrentUser == nil {
|
||||
return ""
|
||||
}
|
||||
return d.CurrentUser.Email
|
||||
}
|
||||
|
||||
func currentDealAlerts(d SettingsData) bool {
|
||||
return d.CurrentUser != nil && d.CurrentUser.EmailDealAlerts
|
||||
}
|
||||
|
||||
func currentWeeklyDigest(d SettingsData) bool {
|
||||
return d.CurrentUser != nil && d.CurrentUser.EmailWeeklyDigest
|
||||
}
|
||||
|
||||
templ Settings(d SettingsData) {
|
||||
@Layout(d.Page, settingsBody(d))
|
||||
}
|
||||
|
||||
@@ -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, "\"></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\"></div><div class=\"border-t border-white/10 pt-4\"><label class=\"v-label\">Resend API Key</label> <input class=\"v-input font-mono\" type=\"password\" name=\"resend_api_key\" autocomplete=\"off\" placeholder=\"re_... (leave blank to keep current value)\">")
|
||||
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, "</div><div><label class=\"v-label\">Resend From Address</label> <input class=\"v-input\" name=\"resend_from\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var11 string
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Values["resend_from"])
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 123, Col: 78}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "\" placeholder=\"Veola <veola@yourdomain.com>\"><div class=\"v-muted text-xs mt-1\">Used for deal-alert and weekly-digest email. Domain must be verified in Resend.</div></div><div class=\"border-t border-white/10 pt-4\"><label class=\"v-label\">Estimated Apify Cost per Call (USD)</label> <input class=\"v-input font-mono\" name=\"apify_cost_per_call\" type=\"number\" min=\"0\" step=\"0.001\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var12 string
|
||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Values["apify_cost_per_call"])
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 128, Col: 139}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\"><div class=\"v-muted text-xs mt-1\">Apify does not report exact spend; this estimate drives the budget figures shown to everyone.</div></div><div><label class=\"v-label\">Monthly Budget (USD)</label> <input class=\"v-input font-mono\" name=\"monthly_budget_usd\" type=\"number\" min=\"0\" step=\"1\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var13 string
|
||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Values["monthly_budget_usd"])
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 133, Col: 133}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\"><div class=\"v-muted text-xs mt-1\">0 hides the budget bar. When set, the dashboard shows month-to-date spend against it.</div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if !d.IsAdmin {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<div class=\"v-muted text-sm\">Read-only for non-admin users.</div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<div class=\"v-muted text-sm\">Read-only for non-admin users.</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<div class=\"flex items-center gap-3 pt-1\"><button class=\"v-btn\" type=\"submit\">Save</button> <button class=\"v-btn-ghost\" type=\"submit\" formaction=\"/settings/test-ntfy\">Test Ntfy</button> <button class=\"v-btn-ghost\" type=\"submit\" formaction=\"/settings/test-apify\">Test Apify</button> <button class=\"v-btn-ghost\" type=\"submit\" formaction=\"/settings/test-ebay\">Test eBay</button></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<div class=\"flex items-center gap-3 pt-1\"><button class=\"v-btn\" type=\"submit\">Save</button> <button class=\"v-btn-ghost\" type=\"submit\" formaction=\"/settings/test-ntfy\">Test Ntfy</button> <button class=\"v-btn-ghost\" type=\"submit\" formaction=\"/settings/test-apify\">Test Apify</button> <button class=\"v-btn-ghost\" type=\"submit\" formaction=\"/settings/test-ebay\">Test eBay</button> <button class=\"v-btn-ghost\" type=\"submit\" formaction=\"/settings/test-resend\">Test Resend</button></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</form>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</form>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if d.TestNtfyOK != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<div class=\"v-flash mt-3\">")
|
||||
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, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
if d.TestApifyOK != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<div class=\"v-flash mt-3\">")
|
||||
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, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
if d.TestEbayOK != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<div class=\"v-flash mt-3\">")
|
||||
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, "</section><section class=\"v-card p-6\"><h2 class=\"font-semibold mb-4\">Change Password</h2>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if d.PasswordError != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<div class=\"v-flash-error\">")
|
||||
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, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
if d.PasswordMsg != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<div class=\"v-flash\">")
|
||||
if d.TestApifyOK != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<div class=\"v-flash mt-3\">")
|
||||
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, "</div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "<form method=\"post\" action=\"/settings/password\" class=\"space-y-4\">")
|
||||
if d.TestEbayOK != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<div class=\"v-flash mt-3\">")
|
||||
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, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
if d.TestResendOK != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<div class=\"v-flash mt-3\">")
|
||||
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, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "<div class=\"text-sm mt-4 border-t border-white/10 pt-4\"><span class=\"v-muted\">Apify calls this month:</span> <span class=\"font-mono\">")
|
||||
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, "</span> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if d.ApifyCostPerCall > 0 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<span class=\"v-muted ml-1\">")
|
||||
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, "</span> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "<span class=\"v-muted ml-3\">today:</span> <span class=\"font-mono\">")
|
||||
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, "</span></div></section><section class=\"v-card p-6\"><h2 class=\"font-semibold mb-4\">Change Password</h2>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if d.PasswordError != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<div class=\"v-flash-error\">")
|
||||
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, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
if d.PasswordMsg != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "<div class=\"v-flash\">")
|
||||
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, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<form method=\"post\" action=\"/settings/password\" class=\"space-y-4\">")
|
||||
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, "<div><label class=\"v-label\">Current Password</label> <input class=\"v-input\" type=\"password\" name=\"current_password\"></div><div><label class=\"v-label\">New Password</label> <input class=\"v-input\" type=\"password\" name=\"new_password\"></div><div><label class=\"v-label\">Confirm New Password</label> <input class=\"v-input\" type=\"password\" name=\"new_password_confirm\"></div><button class=\"v-btn\" type=\"submit\">Update Password</button></form></section>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "<div><label class=\"v-label\">Current Password</label> <input class=\"v-input\" type=\"password\" name=\"current_password\"></div><div><label class=\"v-label\">New Password</label> <input class=\"v-input\" type=\"password\" name=\"new_password\"></div><div><label class=\"v-label\">Confirm New Password</label> <input class=\"v-input\" type=\"password\" name=\"new_password_confirm\"></div><button class=\"v-btn\" type=\"submit\">Update Password</button></form></section><section class=\"v-card p-6\"><h2 class=\"font-semibold mb-4\">Email Notifications</h2>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if d.EmailError != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "<div class=\"v-flash-error\">")
|
||||
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, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
if d.EmailMsg != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "<div class=\"v-flash\">")
|
||||
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, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "<form method=\"post\" action=\"/settings/email\" class=\"space-y-4\">")
|
||||
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, "<div><label class=\"v-label\">Notification Email</label> <input class=\"v-input\" type=\"email\" name=\"email\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var25 string
|
||||
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.ResolveAttributeValue(currentEmail(d))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 209, Col: 77}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var25)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "\" placeholder=\"you@example.com\"><div class=\"v-muted text-xs mt-1\">Where deal alerts and the weekly digest are sent. Requires Resend to be configured by an admin.</div></div><label class=\"flex items-center gap-2\"><input type=\"checkbox\" name=\"email_deal_alerts\" value=\"1\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if currentDealAlerts(d) {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, " checked")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "> <span>Email me deal alerts</span></label> <label class=\"flex items-center gap-2\"><input type=\"checkbox\" name=\"email_weekly_digest\" value=\"1\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if currentWeeklyDigest(d) {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, " checked")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "> <span>Email me the weekly digest (Mondays)</span></label> <button class=\"v-btn\" type=\"submit\">Save Preferences</button></form></section>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if d.IsAdmin {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "<section class=\"v-card p-6\"><h2 class=\"font-semibold mb-4\">Users</h2>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "<section class=\"v-card p-6\"><h2 class=\"font-semibold mb-4\">Users</h2>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if d.UserError != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<div class=\"v-flash-error\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "<div class=\"v-flash-error\">")
|
||||
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, "</div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
if d.UserMsg != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "<div class=\"v-flash\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "<div class=\"v-flash\">")
|
||||
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, "</div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<table class=\"v-table mb-4\"><thead><tr><th>Username</th><th>Role</th><th>Created</th><th></th></tr></thead> <tbody>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "<table class=\"v-table mb-4\"><thead><tr><th>Username</th><th>Role</th><th>Created</th><th></th></tr></thead> <tbody>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for _, u := range d.Users {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "<tr><td>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "<tr><td>")
|
||||
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, "</td><td class=\"v-muted\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "</td><td class=\"v-muted\">")
|
||||
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, "</td><td class=\"v-muted text-sm\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "</td><td class=\"v-muted text-sm\">")
|
||||
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, "</td><td class=\"text-right\"><form class=\"inline\" method=\"post\" action=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "</td><td class=\"text-right\"><form class=\"inline\" method=\"post\" action=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var21 templ.SafeURL
|
||||
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/users/%d/reset-password", u.ID)))
|
||||
var templ_7745c5c3_Var31 templ.SafeURL
|
||||
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/users/%d/reset-password", u.ID)))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 170, Col: 113}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 242, Col: 113}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var22 string
|
||||
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.CSRFToken)
|
||||
var templ_7745c5c3_Var32 string
|
||||
templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.CSRFToken)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 171, Col: 68}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 243, Col: 68}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22)
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var32)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "\"> <input type=\"password\" class=\"v-input inline-block max-w-[140px]\" name=\"new_password\" placeholder=\"new password\"> <button class=\"v-btn-ghost\" type=\"submit\">Reset</button></form><form class=\"inline\" method=\"post\" action=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "\"> <input type=\"password\" class=\"v-input inline-block max-w-[140px]\" name=\"new_password\" placeholder=\"new password\"> <button class=\"v-btn-ghost\" type=\"submit\">Reset</button></form><form class=\"inline\" method=\"post\" action=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var23 templ.SafeURL
|
||||
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/users/%d/delete", u.ID)))
|
||||
var templ_7745c5c3_Var33 templ.SafeURL
|
||||
templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/users/%d/delete", u.ID)))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 175, Col: 105}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 247, Col: 105}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "\" onsubmit=\"return confirm('Remove user?')\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "\" onsubmit=\"return confirm('Remove user?')\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var24 string
|
||||
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.CSRFToken)
|
||||
var templ_7745c5c3_Var34 string
|
||||
templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.CSRFToken)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 176, Col: 68}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 248, Col: 68}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24)
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var34)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "\"> <button class=\"v-btn-ghost\" type=\"submit\">Remove</button></form></td></tr>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "\"> <button class=\"v-btn-ghost\" type=\"submit\">Remove</button></form></td></tr>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "</tbody></table><form method=\"post\" action=\"/users\" class=\"grid md:grid-cols-4 gap-3 items-end\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "</tbody></table><form method=\"post\" action=\"/users\" class=\"grid md:grid-cols-4 gap-3 items-end\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var25 string
|
||||
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.CSRFToken)
|
||||
var templ_7745c5c3_Var35 string
|
||||
templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.CSRFToken)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 185, Col: 63}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 257, Col: 63}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var25)
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var35)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "\"><div><label class=\"v-label\">Username</label> <input class=\"v-input\" name=\"username\"></div><div><label class=\"v-label\">Role</label> <select class=\"v-select\" name=\"role\"><option value=\"user\">user</option> <option value=\"admin\">admin</option></select></div><div><label class=\"v-label\">Initial Password</label> <input class=\"v-input\" type=\"password\" name=\"password\"></div><button class=\"v-btn\" type=\"submit\">Add User</button></form></section>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "\"><div><label class=\"v-label\">Username</label> <input class=\"v-input\" name=\"username\"></div><div><label class=\"v-label\">Role</label> <select class=\"v-select\" name=\"role\"><option value=\"user\">user</option> <option value=\"admin\">admin</option></select></div><div><label class=\"v-label\">Initial Password</label> <input class=\"v-input\" type=\"password\" name=\"password\"></div><button class=\"v-btn\" type=\"submit\">Add User</button></form></section>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "</div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "</div>")
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user