Files
veola/templates/settings.templ
prosolis 4fb1a4553e Harden forward-auth, per-user isolation, and budget accuracy
Code-review fixes on the Authentik/budget/Resend work:

- UpsertForwardUser: avoid 500 lockout on username UNIQUE collision and
  on the concurrent first-login race; normalize emails (lower+trim) so
  case variants don't create duplicate accounts.
- Centralize email management in Authentik: when forward-auth mode is on,
  email is read-only in Veola for every user (not just forward rows);
  pure-local deployments keep editable email for Resend.
- Fail closed on per-user isolation: ownedItem rejects uid==0/orphaned
  items; dashboard and global results error on a userless request.
- Budget accuracy: count Apify usage only after a successful run, and
  count the billed Test Apify run.
- Efficiency: resolve the deal-email recipient once per poll; build the
  settings page once in PostEmailPrefs.
2026-06-20 14:05:04 -07:00

320 lines
13 KiB
Plaintext

package templates
import (
"fmt"
"veola/internal/models"
)
type SettingsData struct {
Page
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
Users []models.User
TestNtfyOK string
TestApifyOK string
TestEbayOK string
TestResendOK string
EbayUsedToday int
EbayDailyLimit int
ApifyToday int
ApifyMonth int
ApifyCostPerCall float64
MonthlyBudget float64
PasswordMsg string
PasswordError string
UserMsg string
UserError string
EmailMsg string
EmailError string
// ForwardAuthMode is true when the deployment delegates identity to
// Authentik (forward-auth). Email is then managed centrally in Authentik
// and read-only in Veola for every user.
ForwardAuthMode bool
}
// 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
// the daily call limit has been hit.
func (d SettingsData) EbayLimitReached() bool {
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) {
<div class="space-y-8 max-w-3xl">
<div>
<h1 class="v-page-title">Settings</h1>
<p class="v-page-sub">Credentials, budget, notifications, and users</p>
</div>
<section class="v-card p-6">
<h2 class="v-section-title mb-4">Apify, eBay and Ntfy</h2>
<form method="post" action="/settings" class="space-y-4">
@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>
<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)"/>
</div>
<div class="text-sm">
<span class="v-muted">eBay API calls today:</span>
if d.EbayDailyLimit > 0 {
<span class="font-mono">{ fmt.Sprintf("%d / %d", d.EbayUsedToday, d.EbayDailyLimit) }</span>
} else {
<span class="font-mono">{ fmt.Sprintf("%d (uncapped)", d.EbayUsedToday) }</span>
}
if d.EbayLimitReached() {
<span class="v-flash-error inline-block ml-2">Limit reached. eBay polling halted until the next reset (midnight US Pacific).</span>
}
</div>
<div class="border-t border-white/10 pt-4">
<label class="v-label">Ntfy Base URL</label>
<input class="v-input" name="ntfy_base_url" value={ d.Values["ntfy_base_url"] }/>
</div>
<div>
<label class="v-label">Ntfy Default Topic</label>
<input class="v-input" name="ntfy_default_topic" value={ d.Values["ntfy_default_topic"] }/>
</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>
<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"] }/>
</div>
<div>
<label class="v-label">Match Confidence Threshold</label>
<input class="v-input font-mono" name="match_confidence_threshold" type="number" min="0" max="1" step="0.05" value={ d.Values["match_confidence_threshold"] }/>
</div>
<div class="border-t border-white/10 pt-4">
<label class="v-label">Resend API Key</label>
<input class="v-input font-mono" type="password" name="resend_api_key" autocomplete="off" placeholder="re_... (leave blank to keep current value)"/>
@credStatus(d, "resend_api_key")
</div>
<div>
<label class="v-label">Resend From Address</label>
<input class="v-input" name="resend_from" value={ d.Values["resend_from"] } placeholder="Veola &lt;veola@yourdomain.com&gt;"/>
<div class="v-muted text-xs mt-1">Used for deal-alert and weekly-digest email. Domain must be verified in Resend.</div>
</div>
<div class="border-t border-white/10 pt-4">
<label class="v-label">Estimated Apify Cost per Call (USD)</label>
<input class="v-input font-mono" name="apify_cost_per_call" type="number" min="0" step="0.001" value={ d.Values["apify_cost_per_call"] }/>
<div class="v-muted text-xs mt-1">Apify does not report exact spend; this estimate drives the budget figures shown to everyone.</div>
</div>
<div>
<label class="v-label">Monthly Budget (USD)</label>
<input class="v-input font-mono" name="monthly_budget_usd" type="number" min="0" step="1" value={ d.Values["monthly_budget_usd"] }/>
<div class="v-muted text-xs mt-1">0 hides the budget bar. When set, the dashboard shows month-to-date spend against it.</div>
</div>
if !d.IsAdmin {
<div class="v-muted text-sm">Read-only for non-admin users.</div>
} else {
<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>
}
</form>
if d.TestNtfyOK != "" {
<div class="v-flash mt-3">{ d.TestNtfyOK }</div>
}
if d.TestApifyOK != "" {
<div class="v-flash mt-3">{ d.TestApifyOK }</div>
}
if d.TestEbayOK != "" {
<div class="v-flash mt-3">{ d.TestEbayOK }</div>
}
if d.TestResendOK != "" {
<div class="v-flash mt-3">{ d.TestResendOK }</div>
}
<div class="text-sm mt-4 border-t border-white/10 pt-4">
<span class="v-muted">Apify calls this month:</span>
<span class="font-mono">{ fmt.Sprintf("%d", d.ApifyMonth) }</span>
if d.ApifyCostPerCall > 0 {
<span class="v-muted ml-1">{ fmt.Sprintf("(~$%.2f est.)", d.ApifyMonthCost()) }</span>
}
<span class="v-muted ml-3">today:</span>
<span class="font-mono">{ fmt.Sprintf("%d", d.ApifyToday) }</span>
</div>
</section>
<section class="v-card p-6">
<h2 class="v-section-title mb-4">Change Password</h2>
if d.PasswordError != "" {
<div class="v-flash-error">{ d.PasswordError }</div>
}
if d.PasswordMsg != "" {
<div class="v-flash">{ d.PasswordMsg }</div>
}
<form method="post" action="/settings/password" class="space-y-4">
@CSRFInput(d.CSRFToken)
<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="v-section-title 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>
if emailManagedExternally(d) {
<input class="v-input" type="email" value={ currentEmail(d) } disabled/>
<div class="v-muted text-xs mt-1">Managed by Authentik. Change your email in your Authentik profile; it syncs here on next sign-in.</div>
} else {
<input class="v-input" type="email" name="email" value={ currentEmail(d) } placeholder="you@example.com"/>
<div class="v-muted text-xs mt-1">Where deal alerts and the weekly digest are sent. Requires Resend to be configured by an admin.</div>
}
</div>
<label class="flex items-center gap-2">
<input type="checkbox" name="email_deal_alerts" value="1" checked?={ currentDealAlerts(d) }/>
<span>Email me deal alerts</span>
</label>
<label class="flex items-center gap-2">
<input type="checkbox" name="email_weekly_digest" value="1" checked?={ currentWeeklyDigest(d) }/>
<span>Email me the weekly digest (Mondays)</span>
</label>
<button class="v-btn" type="submit">Save Preferences</button>
</form>
</section>
if d.IsAdmin {
<section class="v-card p-6">
<h2 class="v-section-title mb-4">Users</h2>
if d.UserError != "" {
<div class="v-flash-error">{ d.UserError }</div>
}
if d.UserMsg != "" {
<div class="v-flash">{ d.UserMsg }</div>
}
<table class="v-table mb-4">
<thead><tr><th>Username</th><th>Role</th><th>Created</th><th></th></tr></thead>
<tbody>
for _, u := range d.Users {
<tr>
<td>{ u.Username }</td>
<td class="v-muted">{ string(u.Role) }</td>
<td class="v-muted text-sm">{ u.CreatedAt.Format("2006-01-02") }</td>
<td class="text-right">
<form class="inline" method="post" action={ templ.SafeURL(fmt.Sprintf("/users/%d/reset-password", u.ID)) }>
<input type="hidden" name="csrf_token" value={ d.CSRFToken }/>
<input type="password" class="v-input inline-block max-w-[140px]" name="new_password" placeholder="new password"/>
<button class="v-btn-ghost" type="submit">Reset</button>
</form>
<form class="inline" method="post" action={ templ.SafeURL(fmt.Sprintf("/users/%d/delete", u.ID)) } onsubmit="return confirm('Remove user?')">
<input type="hidden" name="csrf_token" value={ d.CSRFToken }/>
<button class="v-btn-ghost" type="submit">Remove</button>
</form>
</td>
</tr>
}
</tbody>
</table>
<form method="post" action="/users" class="grid md:grid-cols-4 gap-3 items-end">
<input type="hidden" name="csrf_token" value={ d.CSRFToken }/>
<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>
}
</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
}
// emailManagedExternally reports whether email is owned by Authentik and so is
// read-only in Veola. True for any user when the deployment runs in forward-auth
// mode (email is centralized in Authentik), and for an individual forward row
// regardless of mode (the address is its IdP match key).
func emailManagedExternally(d SettingsData) bool {
return d.ForwardAuthMode || (d.CurrentUser != nil && d.CurrentUser.AuthSource == "forward")
}
templ Settings(d SettingsData) {
@Layout(d.Page, settingsBody(d))
}