diff --git a/internal/auth/forward.go b/internal/auth/forward.go index 33d6158..8723155 100644 --- a/internal/auth/forward.go +++ b/internal/auth/forward.go @@ -110,7 +110,7 @@ func (m *Manager) ForwardAuth(next http.Handler) http.Handler { next.ServeHTTP(w, r) return } - email := strings.TrimSpace(r.Header.Get(fa.HeaderEmail)) + email := strings.ToLower(strings.TrimSpace(r.Header.Get(fa.HeaderEmail))) if email == "" { next.ServeHTTP(w, r) return diff --git a/internal/db/queries.go b/internal/db/queries.go index 8e08eb6..d915373 100644 --- a/internal/db/queries.go +++ b/internal/db/queries.go @@ -142,7 +142,16 @@ func (s *Store) GetUserByID(ctx context.Context, id int64) (*models.User, error) return u, err } +// normalizeEmail lowercases and trims an address so identity matching is +// case-insensitive. The users.email unique index and the forward-auth match key +// are otherwise byte-exact, which would let "Ada@x.com" and "ada@x.com" become +// two accounts for one person. +func normalizeEmail(email string) string { + return strings.ToLower(strings.TrimSpace(email)) +} + func (s *Store) GetUserByEmail(ctx context.Context, email string) (*models.User, error) { + email = normalizeEmail(email) if email == "" { return nil, nil } @@ -176,6 +185,7 @@ func (s *Store) ListUsers(ctx context.Context) ([]models.User, error) { // 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) { + email = normalizeEmail(email) if email == "" { return nil, errors.New("forward-auth requires an email") } @@ -184,11 +194,17 @@ func (s *Store) UpsertForwardUser(ctx context.Context, email, username string, r return nil, err } if existing != nil { - if existing.Role != role || (username != "" && existing.Username != username) { - newName := existing.Username - if username != "" { - newName = username + newName := existing.Username + if username != "" && username != existing.Username { + // The IdP renamed the user. users.username is UNIQUE, so the + // desired name may already belong to a different row; resolve a + // free variant rather than letting the UPDATE fail with a 500. + newName, err = s.uniqueUsername(ctx, username, existing.ID) + if err != nil { + return nil, err } + } + if existing.Role != role || newName != existing.Username { if _, err := s.DB.ExecContext(ctx, `UPDATE users SET role = ?, username = ? WHERE id = ?`, string(role), newName, existing.ID); err != nil { @@ -199,13 +215,27 @@ func (s *Store) UpsertForwardUser(ctx context.Context, email, username string, r } return existing, nil } - if username == "" { - username = email + desired := username + if desired == "" { + desired = email + } + // users.username is UNIQUE; an Authentik username can collide with an + // existing local/forward row. Pick a free variant so a new identity is + // never locked out by a 500 on a username clash. + uname, err := s.uniqueUsername(ctx, desired, 0) + if err != nil { + return nil, err } res, err := s.DB.ExecContext(ctx, `INSERT INTO users (username, password_hash, role, email, auth_source) VALUES (?, ?, ?, ?, 'forward')`, - username, "!forward-auth", string(role), email) + uname, "!forward-auth", string(role), email) if err != nil { + // A concurrent first-login for the same email may have created the row + // between GetUserByEmail and this INSERT; the email unique index then + // rejects us. Re-resolve and let the race winner stand. + if u, getErr := s.GetUserByEmail(ctx, email); getErr == nil && u != nil { + return u, nil + } return nil, err } id, err := res.LastInsertId() @@ -215,6 +245,24 @@ func (s *Store) UpsertForwardUser(ctx context.Context, email, username string, r return s.GetUserByID(ctx, id) } +// uniqueUsername returns desired if it is free (or already held by excludeID), +// otherwise appends a numeric suffix until a free name is found. Bounded so a +// pathological collision set can't loop forever. +func (s *Store) uniqueUsername(ctx context.Context, desired string, excludeID int64) (string, error) { + candidate := desired + for i := 1; i <= 1000; i++ { + ex, err := s.GetUserByUsername(ctx, candidate) + if err != nil { + return "", err + } + if ex == nil || ex.ID == excludeID { + return candidate, nil + } + candidate = fmt.Sprintf("%s-%d", desired, i) + } + return "", fmt.Errorf("no free username for %q", desired) +} + func (s *Store) UpdateUserPassword(ctx context.Context, id int64, hash string) error { _, err := s.DB.ExecContext(ctx, `UPDATE users SET password_hash = ? WHERE id = ?`, hash, id) return err diff --git a/internal/handlers/dashboard.go b/internal/handlers/dashboard.go index 2feec15..b5780e8 100644 --- a/internal/handlers/dashboard.go +++ b/internal/handlers/dashboard.go @@ -1,6 +1,7 @@ package handlers import ( + "errors" "net/http" "veola/internal/db" @@ -31,6 +32,11 @@ func (a *App) GetDashboardRefresh(w http.ResponseWriter, r *http.Request) { func (a *App) dashboardData(r *http.Request) (templates.DashboardData, error) { uid := a.userID(r) + // Fail closed: every store method below treats ownerID 0 as "all owners", + // so a userless request must error rather than render cross-tenant data. + if uid == 0 { + return templates.DashboardData{}, errors.New("dashboard: no authenticated user in context") + } stats, err := a.Store.GetDashboardStatsForUser(r.Context(), uid) if err != nil { return templates.DashboardData{}, err diff --git a/internal/handlers/items.go b/internal/handlers/items.go index be09e88..59ea8b5 100644 --- a/internal/handlers/items.go +++ b/internal/handlers/items.go @@ -28,7 +28,10 @@ func (a *App) ownedItem(r *http.Request, id int64) (*models.Item, error) { if err != nil { return nil, err } - if it == nil || it.UserID != a.userID(r) { + // Fail closed: a zero uid (no authenticated user) must never match, and an + // orphaned item (UserID 0) must never be treated as owned-by-default. + uid := a.userID(r) + if it == nil || uid == 0 || it.UserID != uid { return nil, nil } return it, nil diff --git a/internal/handlers/results.go b/internal/handlers/results.go index 2782563..e5d4f29 100644 --- a/internal/handlers/results.go +++ b/internal/handlers/results.go @@ -106,6 +106,12 @@ func (a *App) GetGlobalResults(w http.ResponseWriter, r *http.Request) { to := strings.TrimSpace(q.Get("to")) uid := a.userID(r) + // Fail closed: ListResults/NextEndingResult treat OwnerID 0 as "all owners", + // so a userless request must not fall through to a cross-tenant view. + if uid == 0 { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } items, err := a.Store.ListItemsForUser(r.Context(), uid) if err != nil { a.serverError(w, r, err) diff --git a/internal/handlers/settings.go b/internal/handlers/settings.go index 273ad57..edbbde0 100644 --- a/internal/handlers/settings.go +++ b/internal/handlers/settings.go @@ -2,6 +2,7 @@ package handlers import ( "fmt" + "log/slog" "net/http" "strconv" "strings" @@ -91,6 +92,7 @@ func (a *App) settingsData(r *http.Request) (templates.SettingsData, error) { ApifyMonth: apifyMonth, ApifyCostPerCall: costPerCall, MonthlyBudget: monthlyBudget, + ForwardAuthMode: a.Cfg.Auth.ForwardAuthEnabled(), }, nil } @@ -108,11 +110,13 @@ func (a *App) PostEmailPrefs(w http.ResponseWriter, r *http.Request) { return } email := strings.TrimSpace(r.PostFormValue("email")) - // Forward-auth (Authentik) owns the address: it is the IdP match key, kept - // in sync on every request. Never overwrite it from the form, or the next - // sign-in would no longer match and orphan the row. Users change their - // email in Authentik; only the opt-in toggles are editable here. - if cur.AuthSource == "forward" { + // In forward-auth mode, Authentik is the single source of truth for email + // addresses — Veola never writes them. For a forward row this also preserves + // the IdP match key (changing it would orphan the row on next sign-in); for + // a break-glass local row it keeps email management centralized in Authentik. + // Only the opt-in toggles are editable here. When forward-auth is off (pure + // local deployment, no IdP), local users set their own Resend address. + if a.Cfg.Auth.ForwardAuthEnabled() || cur.AuthSource == "forward" { email = cur.Email } dealAlerts := r.PostFormValue("email_deal_alerts") == "1" @@ -132,12 +136,8 @@ func (a *App) PostEmailPrefs(w http.ResponseWriter, r *http.Request) { 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 - } + // Reflect the saved values immediately on the already-built data (CurrentUser + // in d predates the write); no need to reassemble the whole settings page. if d.Page.CurrentUser != nil { d.Page.CurrentUser.Email = email d.Page.CurrentUser.EmailDealAlerts = dealAlerts @@ -347,6 +347,11 @@ func (a *App) PostTestApify(w http.ResponseWriter, r *http.Request) { if err != nil { d.TestApifyOK = "Apify test failed: " + err.Error() } else { + // The test launches a real, billed actor run; count it toward the + // budget view like every other Apify run. + if incErr := a.Store.IncrementApifyUsage(r.Context()); incErr != nil { + slog.Error("apify usage increment failed", "err", incErr) + } d.TestApifyOK = fmt.Sprintf("Apify returned %d item(s).", len(raw)) } render(w, r, templates.Settings(d)) diff --git a/internal/scheduler/email.go b/internal/scheduler/email.go index 6c626cd..d4dda2a 100644 --- a/internal/scheduler/email.go +++ b/internal/scheduler/email.go @@ -28,26 +28,31 @@ func (s *Scheduler) emailClient(ctx context.Context) *email.Client { return email.New(apiKey, from) } -// sendDealEmails mails a deal alert to the item's owner, if that user opted -// into deal-alert email and has a destination address. Items are private, so a -// deal only ever notifies the person who is watching it. Entirely best-effort: -// an unconfigured Resend account or a send failure is logged and swallowed. -func (s *Scheduler) sendDealEmails(ctx context.Context, it models.Item, r apify.UnifiedResult) { +// dealMailer resolves the item owner and Resend client for deal-alert email. +// It returns (nil, nil) when email should be skipped (Resend unconfigured, no +// owner, owner opted out, or no address) so the caller can resolve once per +// poll instead of once per result. Best-effort: errors are logged and swallowed. +func (s *Scheduler) dealMailer(ctx context.Context, it models.Item) (*email.Client, *models.User) { client := s.emailClient(ctx) - if !client.Configured() { - return - } - if it.UserID == 0 { - return + if !client.Configured() || it.UserID == 0 { + return nil, nil } owner, err := s.store.GetUserByID(ctx, it.UserID) if err != nil { slog.Error("load item owner failed", "item_id", it.ID, "err", err) - return + return nil, nil } if owner == nil || !owner.EmailDealAlerts || owner.Email == "" { - return + return nil, nil } + return client, owner +} + +// sendDealEmail mails a single deal alert to the resolved owner. The client and +// owner come from dealMailer (resolved once per poll), so this does no DB or +// settings I/O per result. Items are private, so a deal only ever notifies the +// person watching it. +func (s *Scheduler) sendDealEmail(ctx context.Context, client *email.Client, owner *models.User, it models.Item, r apify.UnifiedResult) { subject := fmt.Sprintf("Veola deal: %s", it.Name) price := fmt.Sprintf("%s%.2f", currencyPrefix(r.Currency), r.Price) target := "" diff --git a/internal/scheduler/scheduler.go b/internal/scheduler/scheduler.go index 682720a..e4109b3 100644 --- a/internal/scheduler/scheduler.go +++ b/internal/scheduler/scheduler.go @@ -221,6 +221,10 @@ func (s *Scheduler) RunPoll(ctx context.Context, it models.Item) { ) bestIdx := PickBest(results) + // Resolve the deal-email recipient once: owner and Resend client are + // constant for the whole poll, so per-result sends reuse them instead of + // re-reading settings and the owner row for every alerting result. + dealClient, dealOwner := s.dealMailer(ctx, it) alertsSent := 0 for _, r := range results { exists, err := s.store.ResultExists(ctx, it.ID, r.URL) @@ -247,9 +251,11 @@ func (s *Scheduler) RunPoll(ctx context.Context, it models.Item) { alerted = true alertsSent++ } - // Email opted-in users in addition to ntfy. Best-effort: a mail - // failure must not block result storage or the next alert. - s.sendDealEmails(ctx, it, r) + // Email the owner in addition to ntfy. Best-effort: a mail failure + // must not block result storage or the next alert. + if dealOwner != nil { + s.sendDealEmail(ctx, dealClient, dealOwner, it, r) + } } price := r.Price _, err = s.store.InsertResult(ctx, &models.Result{ @@ -323,10 +329,17 @@ func (s *Scheduler) apifyClient(ctx context.Context) *apify.Client { // 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) { + raw, err := s.apifyClient(ctx).Run(ctx, actorID, input) + if err != nil { + // Count only runs that actually reached Apify. Counting before the call + // would inflate the budget estimate with config errors and failed runs + // that Apify never billed. + return nil, err + } if err := s.store.IncrementApifyUsage(ctx); err != nil { slog.Error("apify usage increment failed", "err", err) } - return s.apifyClient(ctx).Run(ctx, actorID, input) + return raw, nil } // ApifyUsage reports Apify run counts and the cost model used by the budget diff --git a/templates/settings.templ b/templates/settings.templ index 70c4f7e..f09da84 100644 --- a/templates/settings.templ +++ b/templates/settings.templ @@ -31,6 +31,10 @@ type SettingsData struct { 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. @@ -302,11 +306,12 @@ 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. +// 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.CurrentUser != nil && d.CurrentUser.AuthSource == "forward" + return d.ForwardAuthMode || (d.CurrentUser != nil && d.CurrentUser.AuthSource == "forward") } templ Settings(d SettingsData) { diff --git a/templates/settings_templ.go b/templates/settings_templ.go index 0705960..6b09d05 100644 --- a/templates/settings_templ.go +++ b/templates/settings_templ.go @@ -39,6 +39,10 @@ type SettingsData struct { 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. @@ -83,7 +87,7 @@ func credStatus(d SettingsData, key string) templ.Component { var templ_7745c5c3_Var2 string templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(s) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 52, Col: 14} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 56, Col: 14} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) if templ_7745c5c3_Err != nil { @@ -158,7 +162,7 @@ func settingsBody(d SettingsData) templ.Component { var templ_7745c5c3_Var4 string templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Values["ebay_daily_call_limit"]) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 85, Col: 122} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 89, Col: 122} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4) if templ_7745c5c3_Err != nil { @@ -176,7 +180,7 @@ func settingsBody(d SettingsData) templ.Component { var templ_7745c5c3_Var5 string templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d / %d", d.EbayUsedToday, d.EbayDailyLimit)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 90, Col: 89} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 94, Col: 89} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) if templ_7745c5c3_Err != nil { @@ -194,7 +198,7 @@ func settingsBody(d SettingsData) templ.Component { var templ_7745c5c3_Var6 string templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d (uncapped)", d.EbayUsedToday)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 92, Col: 77} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 96, Col: 77} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) if templ_7745c5c3_Err != nil { @@ -218,7 +222,7 @@ func settingsBody(d SettingsData) templ.Component { var templ_7745c5c3_Var7 string templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Values["ntfy_base_url"]) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 100, Col: 82} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 104, Col: 82} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7) if templ_7745c5c3_Err != nil { @@ -231,7 +235,7 @@ func settingsBody(d SettingsData) templ.Component { var templ_7745c5c3_Var8 string templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Values["ntfy_default_topic"]) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 104, Col: 92} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 108, Col: 92} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8) if templ_7745c5c3_Err != nil { @@ -252,7 +256,7 @@ func settingsBody(d SettingsData) templ.Component { var templ_7745c5c3_Var9 string templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Values["global_poll_interval_minutes"]) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 113, Col: 136} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 117, Col: 136} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9) if templ_7745c5c3_Err != nil { @@ -265,7 +269,7 @@ func settingsBody(d SettingsData) templ.Component { var templ_7745c5c3_Var10 string templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Values["match_confidence_threshold"]) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 117, Col: 160} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 121, Col: 160} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10) if templ_7745c5c3_Err != nil { @@ -286,7 +290,7 @@ func settingsBody(d SettingsData) templ.Component { var templ_7745c5c3_Var11 string templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Values["resend_from"]) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 126, Col: 78} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 130, Col: 78} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11) if templ_7745c5c3_Err != nil { @@ -299,7 +303,7 @@ func settingsBody(d SettingsData) templ.Component { var templ_7745c5c3_Var12 string templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Values["apify_cost_per_call"]) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 131, Col: 139} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 135, Col: 139} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12) if templ_7745c5c3_Err != nil { @@ -312,7 +316,7 @@ func settingsBody(d SettingsData) templ.Component { var templ_7745c5c3_Var13 string templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.Values["monthly_budget_usd"]) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 136, Col: 133} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 140, Col: 133} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13) if templ_7745c5c3_Err != nil { @@ -345,7 +349,7 @@ func settingsBody(d SettingsData) templ.Component { 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: 152, Col: 44} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 156, Col: 44} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) if templ_7745c5c3_Err != nil { @@ -364,7 +368,7 @@ func settingsBody(d SettingsData) templ.Component { var templ_7745c5c3_Var15 string templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(d.TestApifyOK) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 155, Col: 45} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 159, Col: 45} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) if templ_7745c5c3_Err != nil { @@ -383,7 +387,7 @@ func settingsBody(d SettingsData) templ.Component { 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: 158, Col: 44} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 162, Col: 44} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) if templ_7745c5c3_Err != nil { @@ -402,7 +406,7 @@ func settingsBody(d SettingsData) templ.Component { 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: 161, Col: 46} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 165, Col: 46} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17)) if templ_7745c5c3_Err != nil { @@ -420,7 +424,7 @@ func settingsBody(d SettingsData) templ.Component { 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: 165, Col: 61} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 169, Col: 61} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18)) if templ_7745c5c3_Err != nil { @@ -438,7 +442,7 @@ func settingsBody(d SettingsData) templ.Component { 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: 167, Col: 82} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 171, Col: 82} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19)) if templ_7745c5c3_Err != nil { @@ -456,7 +460,7 @@ func settingsBody(d SettingsData) templ.Component { 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: 170, Col: 61} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 174, Col: 61} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20)) if templ_7745c5c3_Err != nil { @@ -474,7 +478,7 @@ func settingsBody(d SettingsData) templ.Component { 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: 177, Col: 48} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 181, Col: 48} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21)) if templ_7745c5c3_Err != nil { @@ -493,7 +497,7 @@ func settingsBody(d SettingsData) templ.Component { 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: 180, Col: 40} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 184, Col: 40} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22)) if templ_7745c5c3_Err != nil { @@ -524,7 +528,7 @@ func settingsBody(d SettingsData) templ.Component { var templ_7745c5c3_Var23 string templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(d.EmailError) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 203, Col: 45} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 207, Col: 45} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23)) if templ_7745c5c3_Err != nil { @@ -543,7 +547,7 @@ func settingsBody(d SettingsData) templ.Component { var templ_7745c5c3_Var24 string templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(d.EmailMsg) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 206, Col: 37} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 210, Col: 37} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24)) if templ_7745c5c3_Err != nil { @@ -574,7 +578,7 @@ func settingsBody(d SettingsData) templ.Component { var templ_7745c5c3_Var25 string templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.ResolveAttributeValue(currentEmail(d)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 213, Col: 65} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 217, Col: 65} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var25) if templ_7745c5c3_Err != nil { @@ -592,7 +596,7 @@ func settingsBody(d SettingsData) templ.Component { var templ_7745c5c3_Var26 string templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.ResolveAttributeValue(currentEmail(d)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 216, Col: 78} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 220, Col: 78} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var26) if templ_7745c5c3_Err != nil { @@ -640,7 +644,7 @@ func settingsBody(d SettingsData) templ.Component { var templ_7745c5c3_Var27 string templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(d.UserError) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 236, Col: 45} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 240, Col: 45} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27)) if templ_7745c5c3_Err != nil { @@ -659,7 +663,7 @@ func settingsBody(d SettingsData) templ.Component { var templ_7745c5c3_Var28 string templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(d.UserMsg) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 239, Col: 37} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 243, Col: 37} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28)) if templ_7745c5c3_Err != nil { @@ -682,7 +686,7 @@ func settingsBody(d SettingsData) templ.Component { var templ_7745c5c3_Var29 string templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(u.Username) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 246, Col: 24} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 250, Col: 24} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29)) if templ_7745c5c3_Err != nil { @@ -695,7 +699,7 @@ func settingsBody(d SettingsData) templ.Component { var templ_7745c5c3_Var30 string templ_7745c5c3_Var30, 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: 247, Col: 44} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 251, Col: 44} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30)) if templ_7745c5c3_Err != nil { @@ -708,7 +712,7 @@ func settingsBody(d SettingsData) templ.Component { var templ_7745c5c3_Var31 string templ_7745c5c3_Var31, 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: 248, Col: 70} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 252, Col: 70} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31)) if templ_7745c5c3_Err != nil { @@ -721,7 +725,7 @@ func settingsBody(d SettingsData) templ.Component { var templ_7745c5c3_Var32 templ.SafeURL templ_7745c5c3_Var32, 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: 250, Col: 113} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 254, Col: 113} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32)) if templ_7745c5c3_Err != nil { @@ -734,7 +738,7 @@ func settingsBody(d SettingsData) templ.Component { var templ_7745c5c3_Var33 string templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.CSRFToken) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 251, Col: 68} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 255, Col: 68} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var33) if templ_7745c5c3_Err != nil { @@ -747,7 +751,7 @@ func settingsBody(d SettingsData) templ.Component { var templ_7745c5c3_Var34 templ.SafeURL templ_7745c5c3_Var34, 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: 255, Col: 105} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 259, Col: 105} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34)) if templ_7745c5c3_Err != nil { @@ -760,7 +764,7 @@ func settingsBody(d SettingsData) templ.Component { 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: 256, Col: 68} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 260, Col: 68} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var35) if templ_7745c5c3_Err != nil { @@ -778,7 +782,7 @@ func settingsBody(d SettingsData) templ.Component { var templ_7745c5c3_Var36 string templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.CSRFToken) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 265, Col: 63} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 269, Col: 63} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var36) if templ_7745c5c3_Err != nil { @@ -814,11 +818,12 @@ 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. +// 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.CurrentUser != nil && d.CurrentUser.AuthSource == "forward" + return d.ForwardAuthMode || (d.CurrentUser != nil && d.CurrentUser.AuthSource == "forward") } func Settings(d SettingsData) templ.Component {