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"
|
session_secret = "change-this-to-a-random-32-byte-string-aaaa"
|
||||||
encryption_key = "change-this-to-a-different-random-32-byte-string-bb"
|
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]
|
[apify]
|
||||||
api_key = ""
|
api_key = ""
|
||||||
|
|
||||||
@@ -55,6 +74,26 @@ daily_call_limit = 5000
|
|||||||
base_url = "https://ntfy.yourdomain.com"
|
base_url = "https://ntfy.yourdomain.com"
|
||||||
default_topic = "veola"
|
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]
|
[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
|
global_poll_interval_minutes = 60
|
||||||
match_confidence_threshold = 0.6
|
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
|
Sessions *scs.SessionManager
|
||||||
Store *db.Store
|
Store *db.Store
|
||||||
hmacKey []byte
|
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) {
|
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"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/BurntSushi/toml"
|
"github.com/BurntSushi/toml"
|
||||||
)
|
)
|
||||||
@@ -12,12 +13,53 @@ import (
|
|||||||
type Config struct {
|
type Config struct {
|
||||||
Server ServerConfig `toml:"server"`
|
Server ServerConfig `toml:"server"`
|
||||||
Security SecurityConfig `toml:"security"`
|
Security SecurityConfig `toml:"security"`
|
||||||
|
Auth AuthConfig `toml:"auth"`
|
||||||
Apify ApifyConfig `toml:"apify"`
|
Apify ApifyConfig `toml:"apify"`
|
||||||
Ebay EbayConfig `toml:"ebay"`
|
Ebay EbayConfig `toml:"ebay"`
|
||||||
Ntfy NtfyConfig `toml:"ntfy"`
|
Ntfy NtfyConfig `toml:"ntfy"`
|
||||||
|
Resend ResendConfig `toml:"resend"`
|
||||||
|
Budget BudgetConfig `toml:"budget"`
|
||||||
Scheduler SchedulerConfig `toml:"scheduler"`
|
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,
|
// EbayConfig holds credentials for eBay's official Buy > Browse API. When set,
|
||||||
// eBay marketplaces are polled through the Browse API instead of an Apify
|
// 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
|
// 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 {
|
if c.Ebay.DailyCallLimit == 0 {
|
||||||
c.Ebay.DailyCallLimit = 5000
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,27 @@ func Open(path string) (*sql.DB, error) {
|
|||||||
conn.Close()
|
conn.Close()
|
||||||
return nil, err
|
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
|
return conn, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -94,6 +94,28 @@ func (s *Store) UserCount(ctx context.Context) (int, error) {
|
|||||||
return n, err
|
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) {
|
func (s *Store) CreateUser(ctx context.Context, username, hash string, role models.Role) (int64, error) {
|
||||||
res, err := s.DB.ExecContext(ctx,
|
res, err := s.DB.ExecContext(ctx,
|
||||||
`INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)`,
|
`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) {
|
func (s *Store) GetUserByUsername(ctx context.Context, username string) (*models.User, error) {
|
||||||
row := s.DB.QueryRowContext(ctx,
|
u, err := scanUser(s.DB.QueryRowContext(ctx, userSelect+` WHERE username = ?`, username))
|
||||||
`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) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
return nil, err
|
return u, err
|
||||||
}
|
|
||||||
u.Role = models.Role(role)
|
|
||||||
return &u, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) GetUserByID(ctx context.Context, id int64) (*models.User, error) {
|
func (s *Store) GetUserByID(ctx context.Context, id int64) (*models.User, error) {
|
||||||
row := s.DB.QueryRowContext(ctx,
|
u, err := scanUser(s.DB.QueryRowContext(ctx, userSelect+` WHERE id = ?`, id))
|
||||||
`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) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
return nil, err
|
return u, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetUserByEmail(ctx context.Context, email string) (*models.User, error) {
|
||||||
|
if email == "" {
|
||||||
|
return nil, nil
|
||||||
}
|
}
|
||||||
u.Role = models.Role(role)
|
u, err := scanUser(s.DB.QueryRowContext(ctx, userSelect+` WHERE email = ?`, email))
|
||||||
return &u, nil
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return u, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) ListUsers(ctx context.Context) ([]models.User, error) {
|
func (s *Store) ListUsers(ctx context.Context) ([]models.User, error) {
|
||||||
rows, err := s.DB.QueryContext(ctx,
|
rows, err := s.DB.QueryContext(ctx, userSelect+` ORDER BY id`)
|
||||||
`SELECT id, username, password_hash, role, created_at FROM users ORDER BY id`)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
var out []models.User
|
var out []models.User
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var u models.User
|
u, err := scanUser(rows)
|
||||||
var role string
|
if err != nil {
|
||||||
if err := rows.Scan(&u.ID, &u.Username, &u.PasswordHash, &role, &u.CreatedAt); err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
u.Role = models.Role(role)
|
out = append(out, *u)
|
||||||
out = append(out, u)
|
|
||||||
}
|
}
|
||||||
return out, rows.Err()
|
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 {
|
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)
|
_, err := s.DB.ExecContext(ctx, `UPDATE users SET password_hash = ? WHERE id = ?`, hash, id)
|
||||||
return err
|
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 {
|
func (s *Store) DeleteUser(ctx context.Context, id int64) error {
|
||||||
_, err := s.DB.ExecContext(ctx, `DELETE FROM users WHERE id = ?`, id)
|
_, err := s.DB.ExecContext(ctx, `DELETE FROM users WHERE id = ?`, id)
|
||||||
return err
|
return err
|
||||||
@@ -267,6 +364,47 @@ func (s *Store) IncrementEbayUsage(ctx context.Context) (int, error) {
|
|||||||
return n, nil
|
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 ============
|
// ============ items ============
|
||||||
|
|
||||||
func (s *Store) CreateItem(ctx context.Context, it *models.Item) (int64, error) {
|
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,
|
username TEXT NOT NULL UNIQUE,
|
||||||
password_hash TEXT NOT NULL,
|
password_hash TEXT NOT NULL,
|
||||||
role TEXT NOT NULL DEFAULT 'user',
|
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
|
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 (
|
CREATE TABLE IF NOT EXISTS items (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
@@ -92,7 +102,20 @@ INSERT OR IGNORE INTO settings (key, value) VALUES
|
|||||||
('ntfy_base_url', ''),
|
('ntfy_base_url', ''),
|
||||||
('ntfy_default_topic', 'veola'),
|
('ntfy_default_topic', 'veola'),
|
||||||
('global_poll_interval_minutes', '60'),
|
('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
|
-- ebay_api_usage tracks Browse API calls per day so Veola can surface
|
||||||
-- consumption and halt polling before the developer keyset's daily call
|
-- 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 {
|
if err != nil {
|
||||||
return templates.DashboardData{}, err
|
return templates.DashboardData{}, err
|
||||||
}
|
}
|
||||||
|
apifyToday, apifyMonth, costPerCall, monthlyBudget := a.Scheduler.ApifyUsage(r.Context())
|
||||||
|
ebayUsed, ebayLimit := a.Scheduler.EbayUsage(r.Context())
|
||||||
return templates.DashboardData{
|
return templates.DashboardData{
|
||||||
Page: a.page(r, "Dashboard", "dashboard"),
|
Page: a.page(r, "Dashboard", "dashboard"),
|
||||||
Stats: stats,
|
Stats: stats,
|
||||||
RecentResults: rrs,
|
RecentResults: rrs,
|
||||||
RecentAlerts: alerts,
|
RecentAlerts: alerts,
|
||||||
|
ApifyToday: apifyToday,
|
||||||
|
ApifyMonth: apifyMonth,
|
||||||
|
ApifyCostPerCall: costPerCall,
|
||||||
|
MonthlyBudget: monthlyBudget,
|
||||||
|
EbayToday: ebayUsed,
|
||||||
|
EbayLimit: ebayLimit,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,10 @@ func (a *App) Routes() http.Handler {
|
|||||||
// RealIP rewrites RemoteAddr from X-Forwarded-For — safe only because
|
// RealIP rewrites RemoteAddr from X-Forwarded-For — safe only because
|
||||||
// Veola is expected to sit behind a trusted proxy (Traefik) that sets
|
// Veola is expected to sit behind a trusted proxy (Traefik) that sets
|
||||||
// it; Traefik must be configured to strip client-supplied values.
|
// 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.RealIP)
|
||||||
r.Use(middleware.Recoverer)
|
r.Use(middleware.Recoverer)
|
||||||
r.Use(securityHeaders)
|
r.Use(securityHeaders)
|
||||||
@@ -70,6 +74,10 @@ func (a *App) Routes() http.Handler {
|
|||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
r.Use(a.Auth.Sessions.LoadAndSave)
|
r.Use(a.Auth.Sessions.LoadAndSave)
|
||||||
r.Use(a.Auth.LoadUser)
|
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)
|
r.Use(a.setupGate)
|
||||||
|
|
||||||
// Public auth pages.
|
// 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-ntfy", a.PostTestNtfy)
|
||||||
r.With(a.Auth.CSRFProtect).Post("/settings/test-apify", a.PostTestApify)
|
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-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", 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}/delete", a.PostDeleteUser)
|
||||||
r.With(a.Auth.CSRFProtect, a.Auth.RequireAdmin).Post("/users/{id}/reset-password", a.PostResetPassword)
|
r.With(a.Auth.CSRFProtect, a.Auth.RequireAdmin).Post("/users/{id}/reset-password", a.PostResetPassword)
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ import (
|
|||||||
"veola/templates"
|
"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) {
|
func (a *App) GetItems(w http.ResponseWriter, r *http.Request) {
|
||||||
cat := r.URL.Query().Get("category")
|
cat := r.URL.Query().Get("category")
|
||||||
all, err := a.Store.ListItems(r.Context())
|
all, err := a.Store.ListItems(r.Context())
|
||||||
@@ -57,7 +61,10 @@ func (a *App) GetNewItem(w http.ResponseWriter, r *http.Request) {
|
|||||||
Categories: cats,
|
Categories: cats,
|
||||||
Item: models.Item{
|
Item: models.Item{
|
||||||
NtfyPriority: "default",
|
NtfyPriority: "default",
|
||||||
PollIntervalMinutes: a.Cfg.Scheduler.GlobalPollIntervalMinutes,
|
// 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"},
|
Marketplaces: []string{"ebay.com"},
|
||||||
ListingType: "all",
|
ListingType: "all",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"veola/internal/apify"
|
"veola/internal/apify"
|
||||||
"veola/internal/auth"
|
"veola/internal/auth"
|
||||||
"veola/internal/ebay"
|
"veola/internal/ebay"
|
||||||
|
"veola/internal/email"
|
||||||
"veola/internal/models"
|
"veola/internal/models"
|
||||||
"veola/internal/ntfy"
|
"veola/internal/ntfy"
|
||||||
"veola/templates"
|
"veola/templates"
|
||||||
@@ -24,6 +25,10 @@ var settingsKeys = []string{
|
|||||||
"ntfy_token",
|
"ntfy_token",
|
||||||
"global_poll_interval_minutes",
|
"global_poll_interval_minutes",
|
||||||
"match_confidence_threshold",
|
"match_confidence_threshold",
|
||||||
|
"apify_cost_per_call",
|
||||||
|
"monthly_budget_usd",
|
||||||
|
"resend_api_key",
|
||||||
|
"resend_from",
|
||||||
}
|
}
|
||||||
|
|
||||||
// secretSettingsKeys are credential fields. Their values are never rendered
|
// secretSettingsKeys are credential fields. Their values are never rendered
|
||||||
@@ -34,6 +39,7 @@ var secretSettingsKeys = map[string]bool{
|
|||||||
"ebay_client_id": true,
|
"ebay_client_id": true,
|
||||||
"ebay_client_secret": true,
|
"ebay_client_secret": true,
|
||||||
"ntfy_token": true,
|
"ntfy_token": true,
|
||||||
|
"resend_api_key": true,
|
||||||
}
|
}
|
||||||
|
|
||||||
// credentialStatus reports, per secret key, whether a value is saved in the
|
// 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_id": a.Cfg.Ebay.ClientID,
|
||||||
"ebay_client_secret": a.Cfg.Ebay.ClientSecret,
|
"ebay_client_secret": a.Cfg.Ebay.ClientSecret,
|
||||||
"ntfy_token": "",
|
"ntfy_token": "",
|
||||||
|
"resend_api_key": a.Cfg.Resend.APIKey,
|
||||||
}
|
}
|
||||||
status := make(map[string]string, len(secretSettingsKeys))
|
status := make(map[string]string, len(secretSettingsKeys))
|
||||||
for k := range 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())
|
users, _ := a.Store.ListUsers(r.Context())
|
||||||
cur := auth.CurrentUserFromRequest(r)
|
cur := auth.CurrentUserFromRequest(r)
|
||||||
ebayUsed, ebayLimit := a.Scheduler.EbayUsage(r.Context())
|
ebayUsed, ebayLimit := a.Scheduler.EbayUsage(r.Context())
|
||||||
|
apifyToday, apifyMonth, costPerCall, monthlyBudget := a.Scheduler.ApifyUsage(r.Context())
|
||||||
return templates.SettingsData{
|
return templates.SettingsData{
|
||||||
Page: a.page(r, "Settings", "settings"),
|
Page: a.page(r, "Settings", "settings"),
|
||||||
Values: values,
|
Values: values,
|
||||||
@@ -79,9 +87,103 @@ func (a *App) settingsData(r *http.Request) (templates.SettingsData, error) {
|
|||||||
Users: users,
|
Users: users,
|
||||||
EbayUsedToday: ebayUsed,
|
EbayUsedToday: ebayUsed,
|
||||||
EbayDailyLimit: ebayLimit,
|
EbayDailyLimit: ebayLimit,
|
||||||
|
ApifyToday: apifyToday,
|
||||||
|
ApifyMonth: apifyMonth,
|
||||||
|
ApifyCostPerCall: costPerCall,
|
||||||
|
MonthlyBudget: monthlyBudget,
|
||||||
}, nil
|
}, 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) {
|
func (a *App) GetSettings(w http.ResponseWriter, r *http.Request) {
|
||||||
d, err := a.settingsData(r)
|
d, err := a.settingsData(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -17,6 +17,14 @@ type User struct {
|
|||||||
Username string
|
Username string
|
||||||
PasswordHash string
|
PasswordHash string
|
||||||
Role Role
|
Role Role
|
||||||
|
// 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
|
CreatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -64,6 +65,15 @@ func (s *Scheduler) Start(ctx context.Context) error {
|
|||||||
for _, it := range items {
|
for _, it := range items {
|
||||||
s.register(it)
|
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()
|
s.cron.Start()
|
||||||
slog.Info("scheduler started", "items", len(items))
|
slog.Info("scheduler started", "items", len(items))
|
||||||
return nil
|
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")
|
s.recordError(ctx, it.ID, "no marketplaces configured for this item")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
apifyClient := s.apifyClient(ctx)
|
|
||||||
var results []apify.UnifiedResult
|
var results []apify.UnifiedResult
|
||||||
var errs []string
|
var errs []string
|
||||||
successes := 0
|
successes := 0
|
||||||
@@ -176,7 +185,7 @@ func (s *Scheduler) RunPoll(ctx context.Context, it models.Item) {
|
|||||||
pcQueries = []string{""}
|
pcQueries = []string{""}
|
||||||
}
|
}
|
||||||
for _, q := range pcQueries {
|
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,
|
Query: q, URL: it.URL,
|
||||||
ProxyConfiguration: s.proxyConfig(),
|
ProxyConfiguration: s.proxyConfig(),
|
||||||
})
|
})
|
||||||
@@ -238,6 +247,9 @@ func (s *Scheduler) RunPoll(ctx context.Context, it models.Item) {
|
|||||||
alerted = true
|
alerted = true
|
||||||
alertsSent++
|
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
|
price := r.Price
|
||||||
_, err = s.store.InsertResult(ctx, &models.Result{
|
_, err = s.store.InsertResult(ctx, &models.Result{
|
||||||
@@ -307,6 +319,37 @@ func (s *Scheduler) apifyClient(ctx context.Context) *apify.Client {
|
|||||||
return apify.New(key)
|
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
|
// ebayClient returns the shared eBay client with credentials refreshed from
|
||||||
// settings (falling back to config.toml). The client caches its OAuth token
|
// settings (falling back to config.toml). The client caches its OAuth token
|
||||||
// in memory, so the same instance is reused across polls; credentials are
|
// 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 == "" {
|
if p.actorID == "" {
|
||||||
return nil, fmt.Errorf("no actor configured for %s", p.marketplace)
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -677,7 +720,7 @@ func (s *Scheduler) seedSoldHistoryFor(ctx context.Context, it models.Item, quer
|
|||||||
if actorID == "" {
|
if actorID == "" {
|
||||||
return
|
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,
|
Query: query, Marketplace: marketplace, MaxResults: 50, DaysBack: 30,
|
||||||
ProxyConfiguration: s.proxyConfig(),
|
ProxyConfiguration: s.proxyConfig(),
|
||||||
})
|
})
|
||||||
|
|||||||
12
main.go
12
main.go
@@ -68,6 +68,18 @@ func run(configPath string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("auth manager: %w", err)
|
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)
|
apifyClient := apify.New(cfg.Apify.APIKey)
|
||||||
ntfyClient := ntfy.New(cfg.Ntfy.BaseURL)
|
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-just-swapped { animation: none; }
|
||||||
.v-countdown-critical { 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
|
Stats *db.DashboardStats
|
||||||
RecentResults []ResultRow
|
RecentResults []ResultRow
|
||||||
RecentAlerts []AlertRow
|
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 {
|
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 class="v-muted text-sm mt-1">across { fmt.Sprintf("%d", d.Stats.SavedItemCount) } items</div>
|
||||||
</div>
|
</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="grid md:grid-cols-2 gap-6">
|
||||||
<div class="v-card p-5">
|
<div class="v-card p-5">
|
||||||
<h2 class="font-semibold mb-3">Recent Results</h2>
|
<h2 class="font-semibold mb-3">Recent Results</h2>
|
||||||
|
|||||||
@@ -21,6 +21,39 @@ type DashboardData struct {
|
|||||||
Stats *db.DashboardStats
|
Stats *db.DashboardStats
|
||||||
RecentResults []ResultRow
|
RecentResults []ResultRow
|
||||||
RecentAlerts []AlertRow
|
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 {
|
type ResultRow struct {
|
||||||
@@ -95,7 +128,7 @@ func DashboardBody(d DashboardData) templ.Component {
|
|||||||
var templ_7745c5c3_Var2 string
|
var templ_7745c5c3_Var2 string
|
||||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("$%.2f", d.Stats.PotentialSpend))
|
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("$%.2f", d.Stats.PotentialSpend))
|
||||||
if templ_7745c5c3_Err != nil {
|
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))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -108,7 +141,7 @@ func DashboardBody(d DashboardData) templ.Component {
|
|||||||
var templ_7745c5c3_Var3 string
|
var templ_7745c5c3_Var3 string
|
||||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", d.Stats.PricedItemCount))
|
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", d.Stats.PricedItemCount))
|
||||||
if templ_7745c5c3_Err != nil {
|
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))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -126,7 +159,7 @@ func DashboardBody(d DashboardData) templ.Component {
|
|||||||
var templ_7745c5c3_Var4 string
|
var templ_7745c5c3_Var4 string
|
||||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d items not yet priced.", d.Stats.UnpricedCount))
|
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d items not yet priced.", d.Stats.UnpricedCount))
|
||||||
if templ_7745c5c3_Err != nil {
|
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))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -144,7 +177,7 @@ func DashboardBody(d DashboardData) templ.Component {
|
|||||||
var templ_7745c5c3_Var5 string
|
var templ_7745c5c3_Var5 string
|
||||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("$%.2f", d.Stats.MoneySaved))
|
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("$%.2f", d.Stats.MoneySaved))
|
||||||
if templ_7745c5c3_Err != nil {
|
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))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -157,154 +190,313 @@ func DashboardBody(d DashboardData) templ.Component {
|
|||||||
var templ_7745c5c3_Var6 string
|
var templ_7745c5c3_Var6 string
|
||||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", d.Stats.SavedItemCount))
|
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", d.Stats.SavedItemCount))
|
||||||
if templ_7745c5c3_Err != nil {
|
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))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if len(d.RecentResults) == 0 {
|
var templ_7745c5c3_Var7 string
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<div class=\"v-muted text-sm\">No results yet.</div>")
|
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", d.ApifyMonth))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 107, Col: 75}
|
||||||
}
|
|
||||||
} 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>")
|
|
||||||
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=\"")
|
|
||||||
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)))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 79, 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_Var7))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\">")
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var8 string
|
var templ_7745c5c3_Var8 string
|
||||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(r.ItemName)
|
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("~$%.2f est.", d.ApifyMonthCost()))
|
||||||
if templ_7745c5c3_Err != nil {
|
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: 109, Col: 88}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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, 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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var9 string
|
var templ_7745c5c3_Var9 string
|
||||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(fmtPrice(r.Price, r.Currency))
|
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", d.ApifyToday))
|
||||||
if templ_7745c5c3_Err != nil {
|
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: 114, Col: 75}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</td><td>")
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var10 string
|
var templ_7745c5c3_Var10 string
|
||||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(r.Source)
|
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d / %d", d.EbayToday, d.EbayLimit))
|
||||||
if templ_7745c5c3_Err != nil {
|
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: 119, Col: 93}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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, 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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var11 string
|
var templ_7745c5c3_Var11 string
|
||||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(humanTime(r.FoundAt))
|
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", d.EbayToday))
|
||||||
if templ_7745c5c3_Err != nil {
|
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: 121, Col: 75}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</td></tr>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</tbody></table>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
if d.MonthlyBudget > 0 {
|
||||||
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, 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
|
|
||||||
}
|
|
||||||
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>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<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>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var12 string
|
var templ_7745c5c3_Var12 string
|
||||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(a.ItemName)
|
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("$%.0f", d.MonthlyBudget))
|
||||||
if templ_7745c5c3_Err != nil {
|
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: 127, Col: 82}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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, 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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var13 string
|
var templ_7745c5c3_Var13 string
|
||||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(fmtPrice(a.Price, a.Currency))
|
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%d", d.BudgetPercent()))
|
||||||
if templ_7745c5c3_Err != nil {
|
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: 133, Col: 85}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</span></li>")
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</ul>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</div></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</div></div></div>")
|
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, 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, 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, 31, "<tr><td><a href=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
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: 156, Col: 80}
|
||||||
|
}
|
||||||
|
_, 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, 32, "\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
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: 156, Col: 95}
|
||||||
|
}
|
||||||
|
_, 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, 33, "</a></td><td class=\"font-mono\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
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: 157, Col: 62}
|
||||||
|
}
|
||||||
|
_, 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, "</td><td>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
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: 158, Col: 23}
|
||||||
|
}
|
||||||
|
_, 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, 35, "</td><td class=\"v-muted text-sm\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
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: 159, Col: 59}
|
||||||
|
}
|
||||||
|
_, 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, 36, "</td></tr>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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, 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, 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, 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, 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_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: 174, Col: 26}
|
||||||
|
}
|
||||||
|
_, 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, 42, "</span> <span class=\"font-mono v-price-target\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
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: 175, Col: 78}
|
||||||
|
}
|
||||||
|
_, 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, 43, "</span></li>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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, 45, "</div></div></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -328,61 +520,61 @@ func statCard(label, value, sub string) templ.Component {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
ctx = templ.InitializeContext(ctx)
|
ctx = templ.InitializeContext(ctx)
|
||||||
templ_7745c5c3_Var14 := templ.GetChildren(ctx)
|
templ_7745c5c3_Var22 := templ.GetChildren(ctx)
|
||||||
if templ_7745c5c3_Var14 == nil {
|
if templ_7745c5c3_Var22 == nil {
|
||||||
templ_7745c5c3_Var14 = templ.NopComponent
|
templ_7745c5c3_Var22 = templ.NopComponent
|
||||||
}
|
}
|
||||||
ctx = templ.ClearChildren(ctx)
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var15 string
|
var templ_7745c5c3_Var23 string
|
||||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(label)
|
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(label)
|
||||||
if templ_7745c5c3_Err != nil {
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var16 string
|
var templ_7745c5c3_Var24 string
|
||||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(value)
|
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(value)
|
||||||
if templ_7745c5c3_Err != nil {
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if sub != "" {
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var17 string
|
var templ_7745c5c3_Var25 string
|
||||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(sub)
|
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(sub)
|
||||||
if templ_7745c5c3_Err != nil {
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -406,9 +598,9 @@ func Dashboard(d DashboardData) templ.Component {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
ctx = templ.InitializeContext(ctx)
|
ctx = templ.InitializeContext(ctx)
|
||||||
templ_7745c5c3_Var18 := templ.GetChildren(ctx)
|
templ_7745c5c3_Var26 := templ.GetChildren(ctx)
|
||||||
if templ_7745c5c3_Var18 == nil {
|
if templ_7745c5c3_Var26 == nil {
|
||||||
templ_7745c5c3_Var18 = templ.NopComponent
|
templ_7745c5c3_Var26 = templ.NopComponent
|
||||||
}
|
}
|
||||||
ctx = templ.ClearChildren(ctx)
|
ctx = templ.ClearChildren(ctx)
|
||||||
templ_7745c5c3_Err = Layout(d.Page, DashboardBody(d)).Render(ctx, templ_7745c5c3_Buffer)
|
templ_7745c5c3_Err = Layout(d.Page, DashboardBody(d)).Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
|||||||
@@ -18,12 +18,24 @@ type SettingsData struct {
|
|||||||
TestNtfyOK string
|
TestNtfyOK string
|
||||||
TestApifyOK string
|
TestApifyOK string
|
||||||
TestEbayOK string
|
TestEbayOK string
|
||||||
|
TestResendOK string
|
||||||
EbayUsedToday int
|
EbayUsedToday int
|
||||||
EbayDailyLimit int
|
EbayDailyLimit int
|
||||||
|
ApifyToday int
|
||||||
|
ApifyMonth int
|
||||||
|
ApifyCostPerCall float64
|
||||||
|
MonthlyBudget float64
|
||||||
PasswordMsg string
|
PasswordMsg string
|
||||||
PasswordError string
|
PasswordError string
|
||||||
UserMsg string
|
UserMsg string
|
||||||
UserError 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
|
// 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>
|
<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"] }/>
|
<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>
|
||||||
|
<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 {
|
if !d.IsAdmin {
|
||||||
<div class="v-muted text-sm">Read-only for non-admin users.</div>
|
<div class="v-muted text-sm">Read-only for non-admin users.</div>
|
||||||
} else {
|
} 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-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-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-ebay">Test eBay</button>
|
||||||
|
<button class="v-btn-ghost" type="submit" formaction="/settings/test-resend">Test Resend</button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</form>
|
</form>
|
||||||
@@ -121,6 +154,18 @@ templ settingsBody(d SettingsData) {
|
|||||||
if d.TestEbayOK != "" {
|
if d.TestEbayOK != "" {
|
||||||
<div class="v-flash mt-3">{ d.TestEbayOK }</div>
|
<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>
|
||||||
|
|
||||||
<section class="v-card p-6">
|
<section class="v-card p-6">
|
||||||
@@ -149,6 +194,33 @@ templ settingsBody(d SettingsData) {
|
|||||||
</form>
|
</form>
|
||||||
</section>
|
</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 {
|
if d.IsAdmin {
|
||||||
<section class="v-card p-6">
|
<section class="v-card p-6">
|
||||||
<h2 class="font-semibold mb-4">Users</h2>
|
<h2 class="font-semibold mb-4">Users</h2>
|
||||||
@@ -205,6 +277,23 @@ templ settingsBody(d SettingsData) {
|
|||||||
</div>
|
</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) {
|
templ Settings(d SettingsData) {
|
||||||
@Layout(d.Page, settingsBody(d))
|
@Layout(d.Page, settingsBody(d))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,12 +26,24 @@ type SettingsData struct {
|
|||||||
TestNtfyOK string
|
TestNtfyOK string
|
||||||
TestApifyOK string
|
TestApifyOK string
|
||||||
TestEbayOK string
|
TestEbayOK string
|
||||||
|
TestResendOK string
|
||||||
EbayUsedToday int
|
EbayUsedToday int
|
||||||
EbayDailyLimit int
|
EbayDailyLimit int
|
||||||
|
ApifyToday int
|
||||||
|
ApifyMonth int
|
||||||
|
ApifyCostPerCall float64
|
||||||
|
MonthlyBudget float64
|
||||||
PasswordMsg string
|
PasswordMsg string
|
||||||
PasswordError string
|
PasswordError string
|
||||||
UserMsg string
|
UserMsg string
|
||||||
UserError 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
|
// 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
|
var templ_7745c5c3_Var2 string
|
||||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(s)
|
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(s)
|
||||||
if templ_7745c5c3_Err != nil {
|
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))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -146,7 +158,7 @@ func settingsBody(d SettingsData) templ.Component {
|
|||||||
var templ_7745c5c3_Var4 string
|
var templ_7745c5c3_Var4 string
|
||||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Values["ebay_daily_call_limit"])
|
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Values["ebay_daily_call_limit"])
|
||||||
if templ_7745c5c3_Err != nil {
|
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)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -164,7 +176,7 @@ func settingsBody(d SettingsData) templ.Component {
|
|||||||
var templ_7745c5c3_Var5 string
|
var templ_7745c5c3_Var5 string
|
||||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d / %d", d.EbayUsedToday, d.EbayDailyLimit))
|
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d / %d", d.EbayUsedToday, d.EbayDailyLimit))
|
||||||
if templ_7745c5c3_Err != nil {
|
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))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -182,7 +194,7 @@ func settingsBody(d SettingsData) templ.Component {
|
|||||||
var templ_7745c5c3_Var6 string
|
var templ_7745c5c3_Var6 string
|
||||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d (uncapped)", d.EbayUsedToday))
|
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d (uncapped)", d.EbayUsedToday))
|
||||||
if templ_7745c5c3_Err != nil {
|
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))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -206,7 +218,7 @@ func settingsBody(d SettingsData) templ.Component {
|
|||||||
var templ_7745c5c3_Var7 string
|
var templ_7745c5c3_Var7 string
|
||||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Values["ntfy_base_url"])
|
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Values["ntfy_base_url"])
|
||||||
if templ_7745c5c3_Err != nil {
|
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)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -219,7 +231,7 @@ func settingsBody(d SettingsData) templ.Component {
|
|||||||
var templ_7745c5c3_Var8 string
|
var templ_7745c5c3_Var8 string
|
||||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Values["ntfy_default_topic"])
|
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Values["ntfy_default_topic"])
|
||||||
if templ_7745c5c3_Err != nil {
|
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)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -240,7 +252,7 @@ func settingsBody(d SettingsData) templ.Component {
|
|||||||
var templ_7745c5c3_Var9 string
|
var templ_7745c5c3_Var9 string
|
||||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Values["global_poll_interval_minutes"])
|
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Values["global_poll_interval_minutes"])
|
||||||
if templ_7745c5c3_Err != nil {
|
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)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -253,80 +265,89 @@ func settingsBody(d SettingsData) templ.Component {
|
|||||||
var templ_7745c5c3_Var10 string
|
var templ_7745c5c3_Var10 string
|
||||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Values["match_confidence_threshold"])
|
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Values["match_confidence_threshold"])
|
||||||
if templ_7745c5c3_Err != nil {
|
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)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if !d.IsAdmin {
|
templ_7745c5c3_Err = credStatus(d, "resend_api_key").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<div class=\"v-muted text-sm\">Read-only for non-admin users.</div>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
} else {
|
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=\"")
|
||||||
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>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var11 string
|
var templ_7745c5c3_Var11 string
|
||||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(d.TestNtfyOK)
|
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Values["resend_from"])
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 116, Col: 44}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 123, Col: 78}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</div>")
|
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if d.TestApifyOK != "" {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<div class=\"v-flash mt-3\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var12 string
|
var templ_7745c5c3_Var12 string
|
||||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(d.TestApifyOK)
|
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Values["apify_cost_per_call"])
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 119, Col: 45}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 128, Col: 139}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</div>")
|
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if d.TestEbayOK != "" {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<div class=\"v-flash mt-3\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var13 string
|
var templ_7745c5c3_Var13 string
|
||||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(d.TestEbayOK)
|
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Values["monthly_budget_usd"])
|
||||||
if templ_7745c5c3_Err != nil {
|
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: 133, Col: 133}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
_, 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, 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, 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, 26, "</form>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if d.TestNtfyOK != "" {
|
||||||
|
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_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: 149, Col: 44}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -335,49 +356,155 @@ func settingsBody(d SettingsData) templ.Component {
|
|||||||
return templ_7745c5c3_Err
|
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 d.TestApifyOK != "" {
|
||||||
if templ_7745c5c3_Err != nil {
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<div class=\"v-flash mt-3\">")
|
||||||
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 templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var15 string
|
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 {
|
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))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -385,173 +512,256 @@ func settingsBody(d SettingsData) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if d.IsAdmin {
|
if d.EmailError != "" {
|
||||||
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, 47, "<div class=\"v-flash-error\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if d.UserError != "" {
|
var templ_7745c5c3_Var23 string
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<div class=\"v-flash-error\">")
|
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(d.EmailError)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 200, Col: 45}
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var16 string
|
|
||||||
templ_7745c5c3_Var16, 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}
|
|
||||||
}
|
|
||||||
_, 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, 38, "</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\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var17 string
|
|
||||||
templ_7745c5c3_Var17, 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}
|
|
||||||
}
|
|
||||||
_, 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, 40, "</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>")
|
|
||||||
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>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var18 string
|
|
||||||
templ_7745c5c3_Var18, 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}
|
|
||||||
}
|
|
||||||
_, 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, 43, "</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))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 167, Col: 44}
|
|
||||||
}
|
|
||||||
_, 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, 44, "</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"))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 168, Col: 70}
|
|
||||||
}
|
|
||||||
_, 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, 45, "</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)))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 170, Col: 113}
|
|
||||||
}
|
|
||||||
_, 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, 46, "\"><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)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 171, Col: 68}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22)
|
|
||||||
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=\"")
|
|
||||||
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)))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 175, 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_Var23))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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, 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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var24 string
|
var templ_7745c5c3_Var24 string
|
||||||
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.CSRFToken)
|
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(d.EmailMsg)
|
||||||
if templ_7745c5c3_Err != nil {
|
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: 203, Col: 37}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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, 50, "</div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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, 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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var25 string
|
var templ_7745c5c3_Var25 string
|
||||||
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.CSRFToken)
|
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.ResolveAttributeValue(currentEmail(d))
|
||||||
if templ_7745c5c3_Err != nil {
|
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: 209, Col: 77}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var25)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var25)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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, 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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "</div>")
|
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, 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, 59, "<div class=\"v-flash-error\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
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: 228, Col: 45}
|
||||||
|
}
|
||||||
|
_, 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, 60, "</div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if d.UserMsg != "" {
|
||||||
|
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_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: 231, Col: 37}
|
||||||
|
}
|
||||||
|
_, 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, 62, "</div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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, 64, "<tr><td>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
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: 238, Col: 24}
|
||||||
|
}
|
||||||
|
_, 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, 65, "</td><td class=\"v-muted\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
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: 239, Col: 44}
|
||||||
|
}
|
||||||
|
_, 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, 66, "</td><td class=\"v-muted text-sm\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
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: 240, Col: 70}
|
||||||
|
}
|
||||||
|
_, 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, 67, "</td><td class=\"text-right\"><form class=\"inline\" method=\"post\" action=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
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: 242, Col: 113}
|
||||||
|
}
|
||||||
|
_, 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, 68, "\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
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: 243, Col: 68}
|
||||||
|
}
|
||||||
|
_, 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, 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_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: 247, Col: 105}
|
||||||
|
}
|
||||||
|
_, 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, 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_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: 248, Col: 68}
|
||||||
|
}
|
||||||
|
_, 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, 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, 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_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: 257, Col: 63}
|
||||||
|
}
|
||||||
|
_, 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, 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, 74, "</div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
func Settings(d SettingsData) templ.Component {
|
||||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
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
|
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)
|
ctx = templ.InitializeContext(ctx)
|
||||||
templ_7745c5c3_Var26 := templ.GetChildren(ctx)
|
templ_7745c5c3_Var36 := templ.GetChildren(ctx)
|
||||||
if templ_7745c5c3_Var26 == nil {
|
if templ_7745c5c3_Var36 == nil {
|
||||||
templ_7745c5c3_Var26 = templ.NopComponent
|
templ_7745c5c3_Var36 = templ.NopComponent
|
||||||
}
|
}
|
||||||
ctx = templ.ClearChildren(ctx)
|
ctx = templ.ClearChildren(ctx)
|
||||||
templ_7745c5c3_Err = Layout(d.Page, settingsBody(d)).Render(ctx, templ_7745c5c3_Buffer)
|
templ_7745c5c3_Err = Layout(d.Page, settingsBody(d)).Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
|||||||
Reference in New Issue
Block a user