Compare commits
3 Commits
4fb1a4553e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95272ca4c5 | ||
|
|
56fe8d7c88 | ||
|
|
72f4bdd88a |
@@ -70,9 +70,15 @@ client_secret = ""
|
|||||||
environment = "production"
|
environment = "production"
|
||||||
daily_call_limit = 5000
|
daily_call_limit = 5000
|
||||||
|
|
||||||
|
# ntfy push notifications. If the server has access control
|
||||||
|
# (auth-default-access: deny-all), set username/password to an ntfy user that
|
||||||
|
# has write access to the topics you publish to; Veola authenticates publishes
|
||||||
|
# with HTTP Basic auth. Leave both blank for an open server.
|
||||||
[ntfy]
|
[ntfy]
|
||||||
base_url = "https://ntfy.yourdomain.com"
|
base_url = "https://ntfy.yourdomain.com"
|
||||||
default_topic = "veola"
|
default_topic = "veola"
|
||||||
|
username = ""
|
||||||
|
password = ""
|
||||||
|
|
||||||
# Resend transactional email (https://resend.com) for opt-in deal alerts and
|
# 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
|
# the weekly digest. api_key and from can also be set via /settings. The from
|
||||||
|
|||||||
@@ -47,19 +47,58 @@ http:
|
|||||||
- X-authentik-email
|
- X-authentik-email
|
||||||
- X-authentik-name
|
- X-authentik-name
|
||||||
- X-authentik-groups
|
- X-authentik-groups
|
||||||
|
# Strip client-supplied identity headers on ingress so only the outpost
|
||||||
|
# can set them (runs before `authentik` on the protected router).
|
||||||
|
authentik-strip:
|
||||||
|
headers:
|
||||||
|
customRequestHeaders:
|
||||||
|
X-authentik-username: ""
|
||||||
|
X-authentik-email: ""
|
||||||
|
X-authentik-name: ""
|
||||||
|
X-authentik-groups: ""
|
||||||
|
X-authentik-uid: ""
|
||||||
|
|
||||||
routers:
|
routers:
|
||||||
veola:
|
veola:
|
||||||
rule: "Host(`veola.example.com`)"
|
rule: "Host(`veola.example.com`)"
|
||||||
service: veola
|
service: veola
|
||||||
middlewares:
|
middlewares:
|
||||||
|
- authentik-strip
|
||||||
- authentik
|
- authentik
|
||||||
tls:
|
tls:
|
||||||
certResolver: letsencrypt
|
certResolver: letsencrypt
|
||||||
|
|
||||||
|
# The outpost's own endpoints (callback, start, sign_out) must go straight
|
||||||
|
# to Authentik, NOT through the forwardAuth-protected `veola` router. This
|
||||||
|
# router has NO auth middleware and points at the authentik service.
|
||||||
|
veola-outpost:
|
||||||
|
rule: "Host(`veola.example.com`) && PathPrefix(`/outpost.goauthentik.io/`)"
|
||||||
|
priority: 100
|
||||||
|
service: authentik
|
||||||
|
tls:
|
||||||
|
certResolver: letsencrypt
|
||||||
|
|
||||||
|
services:
|
||||||
|
authentik:
|
||||||
|
loadBalancer:
|
||||||
|
servers:
|
||||||
|
- url: "http://authentik-server:9000/"
|
||||||
```
|
```
|
||||||
|
|
||||||
Traefik must strip any client-supplied `X-Authentik-*` and `X-Forwarded-*`
|
**Router priority — important.** Both routers match the Veola host, so the more
|
||||||
headers on ingress so only the outpost can set them.
|
specific `veola-outpost` must win. Traefik's *default* priority is the rule
|
||||||
|
length, so the catch-all `veola` router (`Host(...)`) gets a priority equal to
|
||||||
|
its length (e.g. `Host(`veola.parodia.dev`)` = 25). Authentik's docs example
|
||||||
|
sets the outpost router to `priority: 15`, which **silently loses** whenever the
|
||||||
|
hostname rule is longer than 15 chars — the catch-all then swallows
|
||||||
|
`/outpost.goauthentik.io/*` and hands it to Veola, which 404s. Symptom: clicking
|
||||||
|
**Sign out** lands on `/outpost.goauthentik.io/sign_out` with a plain
|
||||||
|
`404 page not found`. Fix: give `veola-outpost` a high explicit priority (e.g.
|
||||||
|
`100`) so it always beats the catch-all.
|
||||||
|
|
||||||
|
(Login still works even when the outpost router loses, because the outpost
|
||||||
|
intercepts `callback`/`start` during the forwardAuth `/auth/traefik` round-trip;
|
||||||
|
`sign_out` is the one path that genuinely needs the dedicated router.)
|
||||||
|
|
||||||
## Break-glass
|
## Break-glass
|
||||||
|
|
||||||
|
|||||||
@@ -43,9 +43,9 @@ func (c AuthConfig) ForwardAuthEnabled() bool {
|
|||||||
return strings.EqualFold(strings.TrimSpace(c.Mode), "forward")
|
return strings.EqualFold(strings.TrimSpace(c.Mode), "forward")
|
||||||
}
|
}
|
||||||
|
|
||||||
// ResendConfig holds Resend transactional-email credentials. APIKey and From
|
// ResendConfig holds Resend transactional-email credentials, set only in
|
||||||
// can both be overridden at runtime via /settings (resend_api_key,
|
// config.toml (never via the web UI). From is an RFC-5322 address, e.g.
|
||||||
// resend_from). From is an RFC-5322 address, e.g. "Veola <veola@example.com>".
|
// "Veola <veola@example.com>".
|
||||||
type ResendConfig struct {
|
type ResendConfig struct {
|
||||||
APIKey string `toml:"api_key"`
|
APIKey string `toml:"api_key"`
|
||||||
From string `toml:"from"`
|
From string `toml:"from"`
|
||||||
@@ -126,6 +126,11 @@ type ActorConfig struct {
|
|||||||
type NtfyConfig struct {
|
type NtfyConfig struct {
|
||||||
BaseURL string `toml:"base_url"`
|
BaseURL string `toml:"base_url"`
|
||||||
DefaultTopic string `toml:"default_topic"`
|
DefaultTopic string `toml:"default_topic"`
|
||||||
|
// Username/Password authenticate publishes via HTTP Basic auth, for ntfy
|
||||||
|
// servers with access control (auth-default-access: deny-all). Leave both
|
||||||
|
// empty for an open server.
|
||||||
|
Username string `toml:"username"`
|
||||||
|
Password string `toml:"password"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SchedulerConfig struct {
|
type SchedulerConfig struct {
|
||||||
|
|||||||
@@ -116,8 +116,7 @@ INSERT OR IGNORE INTO settings (key, value) VALUES
|
|||||||
('match_confidence_threshold', '0.6'),
|
('match_confidence_threshold', '0.6'),
|
||||||
('apify_cost_per_call', '0.00'),
|
('apify_cost_per_call', '0.00'),
|
||||||
('monthly_budget_usd', '0.00'),
|
('monthly_budget_usd', '0.00'),
|
||||||
('resend_api_key', ''),
|
('resend_api_key', '');
|
||||||
('resend_from', '');
|
|
||||||
|
|
||||||
-- apify_api_usage tracks Apify actor runs per UTC day so the operator (and
|
-- 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
|
-- every signed-in user) can see consumption and an estimated spend. Apify
|
||||||
|
|||||||
@@ -49,6 +49,14 @@ func (a *App) PostLogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
func (a *App) PostLogout(w http.ResponseWriter, r *http.Request) {
|
func (a *App) PostLogout(w http.ResponseWriter, r *http.Request) {
|
||||||
_ = a.Auth.LogOut(r.Context())
|
_ = a.Auth.LogOut(r.Context())
|
||||||
|
// In forward-auth mode, destroying only the local session is pointless: the
|
||||||
|
// next request still carries a valid Authentik proxy session and would be
|
||||||
|
// re-provisioned immediately. Send the user through the Authentik outpost
|
||||||
|
// sign-out so the IdP session is cleared too.
|
||||||
|
if a.Cfg.Auth.ForwardAuthEnabled() {
|
||||||
|
http.Redirect(w, r, "/outpost.goauthentik.io/sign_out", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,56 +16,18 @@ import (
|
|||||||
"veola/templates"
|
"veola/templates"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// settingsKeys are the non-secret operational settings editable from the
|
||||||
|
// Settings page. API credentials (Apify, eBay, ntfy, Resend) are deliberately
|
||||||
|
// NOT here — they are managed only in config.toml on the server, so the UI
|
||||||
|
// never reads or writes them. The Test buttons verify them.
|
||||||
var settingsKeys = []string{
|
var settingsKeys = []string{
|
||||||
"apify_api_key",
|
|
||||||
"ebay_client_id",
|
|
||||||
"ebay_client_secret",
|
|
||||||
"ebay_daily_call_limit",
|
"ebay_daily_call_limit",
|
||||||
"ntfy_base_url",
|
"ntfy_base_url",
|
||||||
"ntfy_default_topic",
|
"ntfy_default_topic",
|
||||||
"ntfy_token",
|
|
||||||
"global_poll_interval_minutes",
|
"global_poll_interval_minutes",
|
||||||
"match_confidence_threshold",
|
"match_confidence_threshold",
|
||||||
"apify_cost_per_call",
|
"apify_cost_per_call",
|
||||||
"monthly_budget_usd",
|
"monthly_budget_usd",
|
||||||
"resend_api_key",
|
|
||||||
"resend_from",
|
|
||||||
}
|
|
||||||
|
|
||||||
// secretSettingsKeys are credential fields. Their values are never rendered
|
|
||||||
// back into the form, so a blank submission means "leave unchanged" rather
|
|
||||||
// than "clear" — see PostSettings.
|
|
||||||
var secretSettingsKeys = map[string]bool{
|
|
||||||
"apify_api_key": true,
|
|
||||||
"ebay_client_id": true,
|
|
||||||
"ebay_client_secret": true,
|
|
||||||
"ntfy_token": true,
|
|
||||||
"resend_api_key": true,
|
|
||||||
}
|
|
||||||
|
|
||||||
// credentialStatus reports, per secret key, whether a value is saved in the
|
|
||||||
// settings table, inherited from config.toml, or absent — without exposing
|
|
||||||
// the secret itself.
|
|
||||||
func (a *App) credentialStatus(values map[string]string) map[string]string {
|
|
||||||
configVals := map[string]string{
|
|
||||||
"apify_api_key": a.Cfg.Apify.APIKey,
|
|
||||||
"ebay_client_id": a.Cfg.Ebay.ClientID,
|
|
||||||
"ebay_client_secret": a.Cfg.Ebay.ClientSecret,
|
|
||||||
"ntfy_token": "",
|
|
||||||
"resend_api_key": a.Cfg.Resend.APIKey,
|
|
||||||
}
|
|
||||||
status := make(map[string]string, len(secretSettingsKeys))
|
|
||||||
for k := range secretSettingsKeys {
|
|
||||||
switch {
|
|
||||||
case strings.TrimSpace(values[k]) != "":
|
|
||||||
status[k] = "Saved in settings"
|
|
||||||
case strings.TrimSpace(configVals[k]) != "":
|
|
||||||
status[k] = "Set in config.toml"
|
|
||||||
default:
|
|
||||||
status[k] = "Not set"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return status
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) settingsData(r *http.Request) (templates.SettingsData, error) {
|
func (a *App) settingsData(r *http.Request) (templates.SettingsData, error) {
|
||||||
@@ -83,7 +45,6 @@ func (a *App) settingsData(r *http.Request) (templates.SettingsData, error) {
|
|||||||
return templates.SettingsData{
|
return templates.SettingsData{
|
||||||
Page: a.page(r, "Settings", "settings"),
|
Page: a.page(r, "Settings", "settings"),
|
||||||
Values: values,
|
Values: values,
|
||||||
CredentialStatus: a.credentialStatus(values),
|
|
||||||
IsAdmin: cur != nil && cur.Role == models.RoleAdmin,
|
IsAdmin: cur != nil && cur.Role == models.RoleAdmin,
|
||||||
Users: users,
|
Users: users,
|
||||||
EbayUsedToday: ebayUsed,
|
EbayUsedToday: ebayUsed,
|
||||||
@@ -164,10 +125,7 @@ func (a *App) PostTestResend(w http.ResponseWriter, r *http.Request) {
|
|||||||
if apiKey == "" {
|
if apiKey == "" {
|
||||||
apiKey = a.Cfg.Resend.APIKey
|
apiKey = a.Cfg.Resend.APIKey
|
||||||
}
|
}
|
||||||
from := strings.TrimSpace(d.Values["resend_from"])
|
from := a.Cfg.Resend.From
|
||||||
if from == "" {
|
|
||||||
from = a.Cfg.Resend.From
|
|
||||||
}
|
|
||||||
to := ""
|
to := ""
|
||||||
if cur != nil {
|
if cur != nil {
|
||||||
to = cur.Email
|
to = cur.Email
|
||||||
@@ -212,12 +170,6 @@ func (a *App) PostSettings(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
for _, k := range settingsKeys {
|
for _, k := range settingsKeys {
|
||||||
v := strings.TrimSpace(r.PostFormValue(k))
|
v := strings.TrimSpace(r.PostFormValue(k))
|
||||||
// Secret fields are never rendered back into the form, so a blank
|
|
||||||
// submission is the normal state and means "leave unchanged" — not
|
|
||||||
// "clear". (To clear a stored credential, edit the settings table.)
|
|
||||||
if v == "" && secretSettingsKeys[k] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if err := a.Store.SetSetting(r.Context(), k, v); err != nil {
|
if err := a.Store.SetSetting(r.Context(), k, v); err != nil {
|
||||||
a.serverError(w, r, err)
|
a.serverError(w, r, err)
|
||||||
return
|
return
|
||||||
@@ -284,14 +236,19 @@ func (a *App) PostTestNtfy(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
baseURL := strings.TrimSpace(d.Values["ntfy_base_url"])
|
baseURL := strings.TrimSpace(d.Values["ntfy_base_url"])
|
||||||
|
if baseURL == "" {
|
||||||
|
baseURL = a.Cfg.Ntfy.BaseURL
|
||||||
|
}
|
||||||
topic := strings.TrimSpace(d.Values["ntfy_default_topic"])
|
topic := strings.TrimSpace(d.Values["ntfy_default_topic"])
|
||||||
token := strings.TrimSpace(d.Values["ntfy_token"])
|
if topic == "" {
|
||||||
|
topic = a.Cfg.Ntfy.DefaultTopic
|
||||||
|
}
|
||||||
if baseURL == "" || topic == "" {
|
if baseURL == "" || topic == "" {
|
||||||
d.TestNtfyOK = "Set ntfy base URL and default topic first."
|
d.TestNtfyOK = "Set ntfy base URL and default topic first."
|
||||||
render(w, r, templates.Settings(d))
|
render(w, r, templates.Settings(d))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
client := ntfy.NewWithToken(baseURL, token)
|
client := ntfy.NewWithBasicAuth(baseURL, a.Cfg.Ntfy.Username, a.Cfg.Ntfy.Password)
|
||||||
if err := client.Send(r.Context(), ntfy.Notification{
|
if err := client.Send(r.Context(), ntfy.Notification{
|
||||||
Topic: topic,
|
Topic: topic,
|
||||||
Title: "Veola test",
|
Title: "Veola test",
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ import (
|
|||||||
type Client struct {
|
type Client struct {
|
||||||
BaseURL string
|
BaseURL string
|
||||||
Token string
|
Token string
|
||||||
|
// Username/Password take precedence over Token when set, using HTTP Basic
|
||||||
|
// auth (for ntfy servers that authenticate with user accounts rather than
|
||||||
|
// access tokens).
|
||||||
|
Username string
|
||||||
|
Password string
|
||||||
HTTP *http.Client
|
HTTP *http.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,13 +29,23 @@ func New(baseURL string) *Client {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NewWithToken returns a ntfy Client with bearer-token auth set. Use this
|
// NewWithToken returns a ntfy Client with bearer-token auth set. Use this
|
||||||
// when the ntfy server requires authentication.
|
// when the ntfy server requires token authentication.
|
||||||
func NewWithToken(baseURL, token string) *Client {
|
func NewWithToken(baseURL, token string) *Client {
|
||||||
c := New(baseURL)
|
c := New(baseURL)
|
||||||
c.Token = strings.TrimSpace(token)
|
c.Token = strings.TrimSpace(token)
|
||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewWithBasicAuth returns a ntfy Client that authenticates publishes with an
|
||||||
|
// ntfy user account (HTTP Basic auth). Use this for servers configured with
|
||||||
|
// auth-default-access: deny-all and per-user topic ACLs.
|
||||||
|
func NewWithBasicAuth(baseURL, username, password string) *Client {
|
||||||
|
c := New(baseURL)
|
||||||
|
c.Username = strings.TrimSpace(username)
|
||||||
|
c.Password = password
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
type Notification struct {
|
type Notification struct {
|
||||||
Topic string
|
Topic string
|
||||||
Title string
|
Title string
|
||||||
@@ -58,7 +73,9 @@ func (c *Client) Send(ctx context.Context, n Notification) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
req.Header.Set("Content-Type", "text/plain; charset=utf-8")
|
req.Header.Set("Content-Type", "text/plain; charset=utf-8")
|
||||||
if c.Token != "" {
|
if c.Username != "" {
|
||||||
|
req.SetBasicAuth(c.Username, c.Password)
|
||||||
|
} else if c.Token != "" {
|
||||||
req.Header.Set("Authorization", "Bearer "+c.Token)
|
req.Header.Set("Authorization", "Bearer "+c.Token)
|
||||||
}
|
}
|
||||||
if n.Title != "" {
|
if n.Title != "" {
|
||||||
@@ -73,17 +90,18 @@ func (c *Client) Send(ctx context.Context, n Notification) error {
|
|||||||
if n.Click != "" {
|
if n.Click != "" {
|
||||||
req.Header.Set("Click", n.Click)
|
req.Header.Set("Click", n.Click)
|
||||||
}
|
}
|
||||||
tokenLen := len(c.Token)
|
authMode := "none"
|
||||||
tokenPrefix := ""
|
switch {
|
||||||
if tokenLen >= 4 {
|
case c.Username != "":
|
||||||
tokenPrefix = c.Token[:4]
|
authMode = "basic"
|
||||||
|
case c.Token != "":
|
||||||
|
authMode = "bearer"
|
||||||
}
|
}
|
||||||
slog.Info("ntfy publish",
|
slog.Info("ntfy publish",
|
||||||
"url", url,
|
"url", url,
|
||||||
"topic", n.Topic,
|
"topic", n.Topic,
|
||||||
"auth_header_set", c.Token != "",
|
"auth", authMode,
|
||||||
"token_prefix", tokenPrefix,
|
"user", c.Username,
|
||||||
"token_len", tokenLen,
|
|
||||||
)
|
)
|
||||||
resp, err := c.HTTP.Do(req)
|
resp, err := c.HTTP.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -21,11 +21,7 @@ func (s *Scheduler) emailClient(ctx context.Context) *email.Client {
|
|||||||
if v, _ := s.store.GetSetting(ctx, "resend_api_key"); v != "" {
|
if v, _ := s.store.GetSetting(ctx, "resend_api_key"); v != "" {
|
||||||
apiKey = v
|
apiKey = v
|
||||||
}
|
}
|
||||||
from := s.cfg.Resend.From
|
return email.New(apiKey, s.cfg.Resend.From)
|
||||||
if v, _ := s.store.GetSetting(ctx, "resend_from"); v != "" {
|
|
||||||
from = v
|
|
||||||
}
|
|
||||||
return email.New(apiKey, from)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// dealMailer resolves the item owner and Resend client for deal-alert email.
|
// dealMailer resolves the item owner and Resend client for deal-alert email.
|
||||||
|
|||||||
@@ -489,8 +489,7 @@ func (s *Scheduler) sendNotification(ctx context.Context, it models.Item, r apif
|
|||||||
if v, _ := s.store.GetSetting(ctx, "ntfy_base_url"); v != "" {
|
if v, _ := s.store.GetSetting(ctx, "ntfy_base_url"); v != "" {
|
||||||
baseURL = v
|
baseURL = v
|
||||||
}
|
}
|
||||||
token, _ := s.store.GetSetting(ctx, "ntfy_token")
|
client := ntfy.NewWithBasicAuth(baseURL, s.cfg.Ntfy.Username, s.cfg.Ntfy.Password)
|
||||||
client := ntfy.NewWithToken(baseURL, token)
|
|
||||||
return client.Send(ctx, ntfy.Notification{
|
return client.Send(ctx, ntfy.Notification{
|
||||||
Topic: topic,
|
Topic: topic,
|
||||||
Title: fmt.Sprintf("Veola Alert: %s", it.Name),
|
Title: fmt.Sprintf("Veola Alert: %s", it.Name),
|
||||||
|
|||||||
@@ -304,6 +304,23 @@ a.v-feed-name:hover { color: var(--accent); text-decoration: none; }
|
|||||||
}
|
}
|
||||||
.v-side-nav a:hover { color: white; }
|
.v-side-nav a:hover { color: white; }
|
||||||
|
|
||||||
|
/* Sign-out button, styled to read like a nav link (it lives in a <form>). */
|
||||||
|
.v-side-signout {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.7rem 1rem;
|
||||||
|
color: var(--text-2);
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
border-left: 3px solid transparent;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
.v-side-signout:hover { color: white; }
|
||||||
|
|
||||||
/* The brand wordmark at the top of the sidebar is also an anchor (→ /), but
|
/* The brand wordmark at the top of the sidebar is also an anchor (→ /), but
|
||||||
shouldn't pick up the active-item border-left / padding treatment that
|
shouldn't pick up the active-item border-left / padding treatment that
|
||||||
the nav links get. Higher specificity overrides .v-side-nav a defaults. */
|
the nav links get. Higher specificity overrides .v-side-nav a defaults. */
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ templ head(title string) {
|
|||||||
</head>
|
</head>
|
||||||
}
|
}
|
||||||
|
|
||||||
templ Sidebar(active string) {
|
templ Sidebar(active, csrf string) {
|
||||||
<nav class="v-side-nav flex flex-col">
|
<nav class="v-side-nav flex flex-col">
|
||||||
<a href="/" class="v-side-brand px-4 py-5 flex items-center gap-2">
|
<a href="/" class="v-side-brand px-4 py-5 flex items-center gap-2">
|
||||||
<span class="text-2xl">🐝</span>
|
<span class="text-2xl">🐝</span>
|
||||||
@@ -41,7 +41,11 @@ templ Sidebar(active string) {
|
|||||||
<a href="/items" class={ navClass("items", active) }>Items</a>
|
<a href="/items" class={ navClass("items", active) }>Items</a>
|
||||||
<a href="/results" class={ navClass("results", active) }>Results</a>
|
<a href="/results" class={ navClass("results", active) }>Results</a>
|
||||||
<a href="/settings" class={ navClass("settings", active) }>Settings</a>
|
<a href="/settings" class={ navClass("settings", active) }>Settings</a>
|
||||||
<div class="mt-auto px-4 py-4 v-muted text-xs">
|
<form method="post" action="/logout" class="mt-auto">
|
||||||
|
@CSRFInput(csrf)
|
||||||
|
<button type="submit" class="v-side-signout">Sign out</button>
|
||||||
|
</form>
|
||||||
|
<div class="px-4 py-4 v-muted text-xs">
|
||||||
Track. Watch. Notice.
|
Track. Watch. Notice.
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -59,7 +63,7 @@ templ Layout(p Page, body templ.Component) {
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
@head(p.Title)
|
@head(p.Title)
|
||||||
<body class="min-h-screen flex">
|
<body class="min-h-screen flex">
|
||||||
@Sidebar(p.Active)
|
@Sidebar(p.Active, p.CSRFToken)
|
||||||
<main class="flex-1 p-6 max-w-6xl">
|
<main class="flex-1 p-6 max-w-6xl">
|
||||||
if p.Flash != "" {
|
if p.Flash != "" {
|
||||||
<div class="v-flash">{ p.Flash }</div>
|
<div class="v-flash">{ p.Flash }</div>
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ func head(title string) templ.Component {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func Sidebar(active string) templ.Component {
|
func Sidebar(active, csrf string) 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
|
||||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||||
@@ -172,7 +172,15 @@ func Sidebar(active string) 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, 11, "\">Settings</a><div class=\"mt-auto px-4 py-4 v-muted text-xs\">Track. Watch. Notice.</div></nav>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\">Settings</a><form method=\"post\" action=\"/logout\" class=\"mt-auto\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = CSRFInput(csrf).Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<button type=\"submit\" class=\"v-side-signout\">Sign out</button></form><div class=\"px-4 py-4 v-muted text-xs\">Track. Watch. Notice.</div></nav>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -208,7 +216,7 @@ func Layout(p Page, body templ.Component) templ.Component {
|
|||||||
templ_7745c5c3_Var12 = templ.NopComponent
|
templ_7745c5c3_Var12 = templ.NopComponent
|
||||||
}
|
}
|
||||||
ctx = templ.ClearChildren(ctx)
|
ctx = templ.ClearChildren(ctx)
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<!doctype html><html lang=\"en\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<!doctype html><html lang=\"en\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -216,52 +224,52 @@ func Layout(p Page, body templ.Component) 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, 13, "<body class=\"min-h-screen flex\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<body class=\"min-h-screen flex\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = Sidebar(p.Active).Render(ctx, templ_7745c5c3_Buffer)
|
templ_7745c5c3_Err = Sidebar(p.Active, p.CSRFToken).Render(ctx, templ_7745c5c3_Buffer)
|
||||||
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, "<main class=\"flex-1 p-6 max-w-6xl\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<main class=\"flex-1 p-6 max-w-6xl\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if p.Flash != "" {
|
if p.Flash != "" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<div class=\"v-flash\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<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_Var13 string
|
var templ_7745c5c3_Var13 string
|
||||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(p.Flash)
|
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(p.Flash)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/layout.templ`, Line: 65, Col: 35}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/layout.templ`, Line: 69, Col: 35}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(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, 16, "</div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if p.FlashError != "" {
|
if p.FlashError != "" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<div class=\"v-flash-error\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<div class=\"v-flash-error\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var14 string
|
var templ_7745c5c3_Var14 string
|
||||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(p.FlashError)
|
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(p.FlashError)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/layout.templ`, Line: 68, Col: 46}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/layout.templ`, Line: 72, Col: 46}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
_, 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
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</div>")
|
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
|
||||||
}
|
}
|
||||||
@@ -270,7 +278,7 @@ func Layout(p Page, body templ.Component) 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, 19, "</main></body></html>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</main></body></html>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -300,7 +308,7 @@ func Bare(p Page, body templ.Component) templ.Component {
|
|||||||
templ_7745c5c3_Var15 = templ.NopComponent
|
templ_7745c5c3_Var15 = templ.NopComponent
|
||||||
}
|
}
|
||||||
ctx = templ.ClearChildren(ctx)
|
ctx = templ.ClearChildren(ctx)
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<!doctype html><html lang=\"en\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<!doctype html><html lang=\"en\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -308,7 +316,7 @@ func Bare(p Page, body templ.Component) 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, 21, "<body class=\"min-h-screen\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<body class=\"min-h-screen\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -316,7 +324,7 @@ func Bare(p Page, body templ.Component) 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, 22, "</body></html>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</body></html>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -346,20 +354,20 @@ func CSRFInput(token string) templ.Component {
|
|||||||
templ_7745c5c3_Var16 = templ.NopComponent
|
templ_7745c5c3_Var16 = templ.NopComponent
|
||||||
}
|
}
|
||||||
ctx = templ.ClearChildren(ctx)
|
ctx = templ.ClearChildren(ctx)
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<input type=\"hidden\" name=\"csrf_token\" value=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<input type=\"hidden\" name=\"csrf_token\" value=\"")
|
||||||
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_Var17 string
|
||||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(token)
|
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(token)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/layout.templ`, Line: 89, Col: 53}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/layout.templ`, Line: 93, Col: 53}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
|
||||||
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, "\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,10 +9,6 @@ import (
|
|||||||
type SettingsData struct {
|
type SettingsData struct {
|
||||||
Page
|
Page
|
||||||
Values map[string]string
|
Values map[string]string
|
||||||
// CredentialStatus maps each secret settings key to a human-readable
|
|
||||||
// status ("Saved in settings", "Set in config.toml", "Not set"). Secret
|
|
||||||
// values are never rendered into the form itself.
|
|
||||||
CredentialStatus map[string]string
|
|
||||||
IsAdmin bool
|
IsAdmin bool
|
||||||
Users []models.User
|
Users []models.User
|
||||||
TestNtfyOK string
|
TestNtfyOK string
|
||||||
@@ -48,16 +44,6 @@ func (d SettingsData) EbayLimitReached() bool {
|
|||||||
return d.EbayDailyLimit > 0 && d.EbayUsedToday >= d.EbayDailyLimit
|
return d.EbayDailyLimit > 0 && d.EbayUsedToday >= d.EbayDailyLimit
|
||||||
}
|
}
|
||||||
|
|
||||||
// credStatus renders the "Saved in settings / Set in config.toml / Not set"
|
|
||||||
// indicator for a secret field without ever printing the secret itself.
|
|
||||||
templ credStatus(d SettingsData, key string) {
|
|
||||||
if s := d.CredentialStatus[key]; s != "" {
|
|
||||||
<div class="v-muted text-xs mt-1">
|
|
||||||
Status: { s }
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
templ settingsBody(d SettingsData) {
|
templ settingsBody(d SettingsData) {
|
||||||
<div class="space-y-8 max-w-3xl">
|
<div class="space-y-8 max-w-3xl">
|
||||||
<div>
|
<div>
|
||||||
@@ -67,23 +53,11 @@ templ settingsBody(d SettingsData) {
|
|||||||
|
|
||||||
<section class="v-card p-6">
|
<section class="v-card p-6">
|
||||||
<h2 class="v-section-title mb-4">Apify, eBay and Ntfy</h2>
|
<h2 class="v-section-title mb-4">Apify, eBay and Ntfy</h2>
|
||||||
|
<div class="v-muted text-sm mb-4">
|
||||||
|
API credentials (Apify, eBay, ntfy, Resend) are managed in config.toml on the server, not here. Use the Test buttons to verify them.
|
||||||
|
</div>
|
||||||
<form method="post" action="/settings" class="space-y-4">
|
<form method="post" action="/settings" class="space-y-4">
|
||||||
@CSRFInput(d.CSRFToken)
|
@CSRFInput(d.CSRFToken)
|
||||||
<div>
|
|
||||||
<label class="v-label">Apify API Key</label>
|
|
||||||
<input class="v-input font-mono" type="password" name="apify_api_key" autocomplete="off" placeholder="leave blank to keep current value"/>
|
|
||||||
@credStatus(d, "apify_api_key")
|
|
||||||
</div>
|
|
||||||
<div class="border-t border-white/10 pt-4">
|
|
||||||
<label class="v-label">eBay App ID (Client ID)</label>
|
|
||||||
<input class="v-input font-mono" type="password" name="ebay_client_id" autocomplete="off" placeholder="used for eBay marketplaces instead of Apify"/>
|
|
||||||
@credStatus(d, "ebay_client_id")
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="v-label">eBay Cert ID (Client Secret)</label>
|
|
||||||
<input class="v-input font-mono" type="password" name="ebay_client_secret" autocomplete="off" placeholder="leave blank to keep current value"/>
|
|
||||||
@credStatus(d, "ebay_client_secret")
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<label class="v-label">eBay Daily Call Limit</label>
|
<label class="v-label">eBay Daily Call Limit</label>
|
||||||
<input class="v-input font-mono" name="ebay_daily_call_limit" type="number" value={ d.Values["ebay_daily_call_limit"] } placeholder="5000 (blank uses config default)"/>
|
<input class="v-input font-mono" name="ebay_daily_call_limit" type="number" value={ d.Values["ebay_daily_call_limit"] } placeholder="5000 (blank uses config default)"/>
|
||||||
@@ -107,11 +81,6 @@ templ settingsBody(d SettingsData) {
|
|||||||
<label class="v-label">Ntfy Default Topic</label>
|
<label class="v-label">Ntfy Default Topic</label>
|
||||||
<input class="v-input" name="ntfy_default_topic" value={ d.Values["ntfy_default_topic"] }/>
|
<input class="v-input" name="ntfy_default_topic" value={ d.Values["ntfy_default_topic"] }/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<label class="v-label">Ntfy Token</label>
|
|
||||||
<input class="v-input font-mono" type="password" name="ntfy_token" autocomplete="off" placeholder="tk_... (leave blank to keep current value)"/>
|
|
||||||
@credStatus(d, "ntfy_token")
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<label class="v-label">Global Poll Interval (minutes)</label>
|
<label class="v-label">Global Poll Interval (minutes)</label>
|
||||||
<input class="v-input font-mono" name="global_poll_interval_minutes" type="number" value={ d.Values["global_poll_interval_minutes"] }/>
|
<input class="v-input font-mono" name="global_poll_interval_minutes" type="number" value={ d.Values["global_poll_interval_minutes"] }/>
|
||||||
@@ -120,16 +89,6 @@ 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">
|
<div class="border-t border-white/10 pt-4">
|
||||||
<label class="v-label">Estimated Apify Cost per Call (USD)</label>
|
<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"] }/>
|
<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"] }/>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user