Apply the dashboard's refined direction to the rest of the app: - Items: page-title header, product thumbnail (best-price image) beside each item name via the shared thumbSm component. - Results: refined headers; per-item and global tables get standardized thumbnails (global table gains a thumbnail column). - Settings: page-title + subtitle, section headings restyled. - Item form: page-title header. - Shared: thumbSm component + compact thumbnail CSS variant.
315 lines
12 KiB
Plaintext
315 lines
12 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
|
|
}
|
|
|
|
// 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 <veola@yourdomain.com>"/>
|
|
<div class="v-muted text-xs mt-1">Used for deal-alert and weekly-digest email. Domain must be verified in Resend.</div>
|
|
</div>
|
|
<div class="border-t border-white/10 pt-4">
|
|
<label class="v-label">Estimated Apify Cost per Call (USD)</label>
|
|
<input class="v-input font-mono" name="apify_cost_per_call" type="number" min="0" step="0.001" value={ d.Values["apify_cost_per_call"] }/>
|
|
<div class="v-muted text-xs mt-1">Apify does not report exact spend; this estimate drives the budget figures shown to everyone.</div>
|
|
</div>
|
|
<div>
|
|
<label class="v-label">Monthly Budget (USD)</label>
|
|
<input class="v-input font-mono" name="monthly_budget_usd" type="number" min="0" step="1" value={ d.Values["monthly_budget_usd"] }/>
|
|
<div class="v-muted text-xs mt-1">0 hides the budget bar. When set, the dashboard shows month-to-date spend against it.</div>
|
|
</div>
|
|
if !d.IsAdmin {
|
|
<div class="v-muted text-sm">Read-only for non-admin users.</div>
|
|
} else {
|
|
<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 the signed-in user's email comes from
|
|
// an identity provider (Authentik forward-auth) and so is read-only here. The
|
|
// address is the IdP match key; letting them edit it would orphan the row.
|
|
func emailManagedExternally(d SettingsData) bool {
|
|
return d.CurrentUser != nil && d.CurrentUser.AuthSource == "forward"
|
|
}
|
|
|
|
templ Settings(d SettingsData) {
|
|
@Layout(d.Page, settingsBody(d))
|
|
}
|