Let Authentik own forward-auth user emails; add forward-auth tests

For SSO users the email address is the IdP match key, so allowing them
to edit it in Veola orphaned the row on next sign-in. PostEmailPrefs now
keeps the synced Authentik email for forward users (only the deal-alert
and digest toggles stay editable); the settings form renders the field
read-only with a Managed by Authentik note. Local break-glass users keep
an editable field.

Add forward-auth integration tests over the full Routes() chain: trusted
proxy provisions an admin, untrusted peer's spoofed headers are ignored,
role re-syncs per request, and the email-prefs round trip confirms a form
cannot change a forward user's IdP email.
This commit is contained in:
prosolis
2026-06-20 11:26:02 -07:00
parent 7c95e4fd4e
commit 2fd4019103
4 changed files with 364 additions and 91 deletions

View File

@@ -4,7 +4,9 @@ import (
"context" "context"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url"
"path/filepath" "path/filepath"
"regexp"
"strings" "strings"
"testing" "testing"
@@ -113,3 +115,220 @@ func TestLoginPageRenders(t *testing.T) {
t.Fatalf("body missing <form>") t.Fatalf("body missing <form>")
} }
} }
// ============ forward-auth (Authentik) integration ============
// Authentik header names, matching internal/config defaults.
const (
hdrEmail = "X-Authentik-Email"
hdrUsername = "X-Authentik-Username"
hdrName = "X-Authentik-Name"
hdrGroups = "X-Authentik-Groups"
)
// enableForwardAuth turns on forward-auth trust for the 10.0.0.0/8 range with
// admin group "veola-admins", mirroring a Traefik-in-front deployment.
func enableForwardAuth(t *testing.T, app *App) {
t.Helper()
fa, err := auth.NewForwardAuthConfig(hdrUsername, hdrEmail, hdrName, hdrGroups, "veola-admins",
[]string{"10.0.0.0/8"})
if err != nil {
t.Fatalf("NewForwardAuthConfig: %v", err)
}
app.Auth.SetForwardAuth(fa)
}
// testClient threads cookies across requests so an scs session survives a
// GET-then-POST flow, and applies a fixed peer address + identity headers.
type testClient struct {
h http.Handler
remoteAddr string
headers map[string]string
cookies map[string]*http.Cookie
}
func newClient(h http.Handler, remoteAddr string, headers map[string]string) *testClient {
return &testClient{h: h, remoteAddr: remoteAddr, headers: headers, cookies: map[string]*http.Cookie{}}
}
func (c *testClient) do(method, target string, form url.Values) *httptest.ResponseRecorder {
var req *http.Request
if form != nil {
req = httptest.NewRequest(method, target, strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
} else {
req = httptest.NewRequest(method, target, nil)
}
if c.remoteAddr != "" {
req.RemoteAddr = c.remoteAddr
}
for k, v := range c.headers {
req.Header.Set(k, v)
}
for _, ck := range c.cookies {
req.AddCookie(ck)
}
rec := httptest.NewRecorder()
c.h.ServeHTTP(rec, req)
for _, ck := range rec.Result().Cookies() {
c.cookies[ck.Name] = ck
}
return rec
}
var csrfRe = regexp.MustCompile(`name="csrf_token" value="([^"]+)"`)
func extractCSRF(t *testing.T, body string) string {
t.Helper()
m := csrfRe.FindStringSubmatch(body)
if m == nil {
t.Fatal("no csrf_token field in body")
}
return m[1]
}
func TestForwardAuthProvisionsAdminFromTrustedProxy(t *testing.T) {
app, h := newTestApp(t)
enableForwardAuth(t, app)
c := newClient(h, "10.0.0.5:5555", map[string]string{
hdrEmail: "ada@example.com",
hdrUsername: "ada",
hdrGroups: "users|veola-admins",
})
rec := c.do(http.MethodGet, "/", nil)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (provisioned dashboard); body: %s", rec.Code, rec.Body.String())
}
u, err := app.Store.GetUserByEmail(context.Background(), "ada@example.com")
if err != nil || u == nil {
t.Fatalf("GetUserByEmail: u=%v err=%v", u, err)
}
if u.Role != models.RoleAdmin {
t.Errorf("role = %q, want admin (from veola-admins group)", u.Role)
}
if u.AuthSource != "forward" {
t.Errorf("auth_source = %q, want forward", u.AuthSource)
}
}
func TestForwardAuthUntrustedPeerIgnoresSpoofedHeaders(t *testing.T) {
app, h := newTestApp(t)
enableForwardAuth(t, app)
// Same headers, but the direct peer is NOT in the trusted range: the
// headers must be ignored and no user provisioned.
c := newClient(h, "203.0.113.9:5555", map[string]string{
hdrEmail: "mallory@example.com",
hdrUsername: "mallory",
hdrGroups: "veola-admins",
})
rec := c.do(http.MethodGet, "/", nil)
if rec.Code != http.StatusSeeOther {
t.Fatalf("status = %d, want 303 (no trust, setup gate)", rec.Code)
}
if loc := rec.Header().Get("Location"); loc != "/setup" {
t.Fatalf("Location = %q, want /setup", loc)
}
if u, _ := app.Store.GetUserByEmail(context.Background(), "mallory@example.com"); u != nil {
t.Fatalf("spoofed header provisioned a user: %+v", u)
}
}
func TestForwardAuthReSyncsRoleOnEveryRequest(t *testing.T) {
app, h := newTestApp(t)
enableForwardAuth(t, app)
admin := newClient(h, "10.0.0.5:5555", map[string]string{
hdrEmail: "bea@example.com",
hdrGroups: "veola-admins",
})
if rec := admin.do(http.MethodGet, "/", nil); rec.Code != http.StatusOK {
t.Fatalf("first request status = %d, want 200", rec.Code)
}
u, _ := app.Store.GetUserByEmail(context.Background(), "bea@example.com")
if u == nil || u.Role != models.RoleAdmin {
t.Fatalf("after admin login, role = %v, want admin", u)
}
// IdP later drops the admin group: the next request must downgrade the role.
demoted := newClient(h, "10.0.0.5:5555", map[string]string{
hdrEmail: "bea@example.com",
hdrGroups: "users",
})
if rec := demoted.do(http.MethodGet, "/", nil); rec.Code != http.StatusOK {
t.Fatalf("second request status = %d, want 200", rec.Code)
}
u, _ = app.Store.GetUserByEmail(context.Background(), "bea@example.com")
if u == nil || u.Role != models.RoleUser {
t.Fatalf("after group drop, role = %v, want user", u)
}
}
func TestForwardAuthEmailPrefsRoundTrip(t *testing.T) {
app, h := newTestApp(t)
enableForwardAuth(t, app)
// A forward-auth session authenticates without local login; carry its
// cookie + CSRF token into the POST.
c := newClient(h, "10.0.0.5:5555", map[string]string{
hdrEmail: "cleo@example.com",
hdrGroups: "users",
})
rec := c.do(http.MethodGet, "/settings", nil)
if rec.Code != http.StatusOK {
t.Fatalf("GET /settings = %d, want 200", rec.Code)
}
token := extractCSRF(t, rec.Body.String())
provisioned, _ := app.Store.GetUserByEmail(context.Background(), "cleo@example.com")
if provisioned == nil {
t.Fatal("forward user not provisioned by GET /settings")
}
// A forward user can toggle opt-ins, but a form that tries to change the
// email must be ignored: the address is the Authentik match key, so
// changing it here would orphan the row on the next sign-in.
form := url.Values{
"csrf_token": {token},
"email": {"cleo+spoofed@example.com"},
"email_deal_alerts": {"1"},
"email_weekly_digest": {"1"},
}
rec = c.do(http.MethodPost, "/settings/email", form)
if rec.Code != http.StatusOK {
t.Fatalf("POST /settings/email = %d, want 200; body: %s", rec.Code, rec.Body.String())
}
// Email is unchanged (still the IdP address), so the row still re-matches.
u, err := app.Store.GetUserByEmail(context.Background(), "cleo@example.com")
if err != nil || u == nil {
t.Fatalf("forward user orphaned: GetUserByEmail(IdP addr) u=%v err=%v", u, err)
}
if u.ID != provisioned.ID {
t.Errorf("re-matched a different row: id %d != %d", u.ID, provisioned.ID)
}
if u.Email != "cleo@example.com" {
t.Errorf("email = %q, want the unchanged IdP address cleo@example.com", u.Email)
}
if !u.EmailDealAlerts || !u.EmailWeeklyDigest {
t.Errorf("opt-ins = (deal:%v digest:%v), want both true", u.EmailDealAlerts, u.EmailWeeklyDigest)
}
// The user appears in the deal-alert recipient list under their IdP address.
recips, err := app.Store.UsersWithDealAlerts(context.Background())
if err != nil {
t.Fatalf("UsersWithDealAlerts: %v", err)
}
if !containsEmail(recips, "cleo@example.com") {
t.Errorf("deal-alert recipients %v missing cleo", recips)
}
}
func containsEmail(us []models.User, email string) bool {
for _, u := range us {
if u.Email == email {
return true
}
}
return false
}

View File

@@ -108,6 +108,13 @@ func (a *App) PostEmailPrefs(w http.ResponseWriter, r *http.Request) {
return return
} }
email := strings.TrimSpace(r.PostFormValue("email")) 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" {
email = cur.Email
}
dealAlerts := r.PostFormValue("email_deal_alerts") == "1" dealAlerts := r.PostFormValue("email_deal_alerts") == "1"
weeklyDigest := r.PostFormValue("email_weekly_digest") == "1" weeklyDigest := r.PostFormValue("email_weekly_digest") == "1"

View File

@@ -206,8 +206,13 @@ templ settingsBody(d SettingsData) {
@CSRFInput(d.CSRFToken) @CSRFInput(d.CSRFToken)
<div> <div>
<label class="v-label">Notification Email</label> <label class="v-label">Notification Email</label>
<input class="v-input" type="email" name="email" value={ currentEmail(d) } placeholder="you@example.com"/> if emailManagedExternally(d) {
<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> <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> </div>
<label class="flex items-center gap-2"> <label class="flex items-center gap-2">
<input type="checkbox" name="email_deal_alerts" value="1" checked?={ currentDealAlerts(d) }/> <input type="checkbox" name="email_deal_alerts" value="1" checked?={ currentDealAlerts(d) }/>
@@ -294,6 +299,13 @@ func currentWeeklyDigest(d SettingsData) bool {
return d.CurrentUser != nil && d.CurrentUser.EmailWeeklyDigest 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) { templ Settings(d SettingsData) {
@Layout(d.Page, settingsBody(d)) @Layout(d.Page, settingsBody(d))
} }

View File

@@ -562,206 +562,234 @@ func settingsBody(d SettingsData) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "<div><label class=\"v-label\">Notification Email</label> <input class=\"v-input\" type=\"email\" name=\"email\" value=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "<div><label class=\"v-label\">Notification Email</label> ")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var25 string if emailManagedExternally(d) {
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.ResolveAttributeValue(currentEmail(d)) templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "<input class=\"v-input\" type=\"email\" value=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 209, Col: 77} return templ_7745c5c3_Err
}
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: 210, Col: 65}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var25)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "\" 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>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "<input class=\"v-input\" type=\"email\" name=\"email\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
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: 213, Col: 78}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var26)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "\" 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>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var25) templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "</div><label class=\"flex items-center gap-2\"><input type=\"checkbox\" name=\"email_deal_alerts\" value=\"1\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "\" placeholder=\"you@example.com\"><div class=\"v-muted text-xs mt-1\">Where deal alerts and the weekly digest are sent. Requires Resend to be configured by an admin.</div></div><label class=\"flex items-center gap-2\"><input type=\"checkbox\" name=\"email_deal_alerts\" value=\"1\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if currentDealAlerts(d) { if currentDealAlerts(d) {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, " checked") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, " checked")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "> <span>Email me deal alerts</span></label> <label class=\"flex items-center gap-2\"><input type=\"checkbox\" name=\"email_weekly_digest\" value=\"1\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "> <span>Email me deal alerts</span></label> <label class=\"flex items-center gap-2\"><input type=\"checkbox\" name=\"email_weekly_digest\" value=\"1\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if currentWeeklyDigest(d) { if currentWeeklyDigest(d) {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, " checked") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, " checked")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "> <span>Email me the weekly digest (Mondays)</span></label> <button class=\"v-btn\" type=\"submit\">Save Preferences</button></form></section>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "> <span>Email me the weekly digest (Mondays)</span></label> <button class=\"v-btn\" type=\"submit\">Save Preferences</button></form></section>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if d.IsAdmin { if d.IsAdmin {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "<section class=\"v-card p-6\"><h2 class=\"font-semibold mb-4\">Users</h2>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "<section class=\"v-card p-6\"><h2 class=\"font-semibold mb-4\">Users</h2>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if d.UserError != "" { if d.UserError != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "<div class=\"v-flash-error\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "<div class=\"v-flash-error\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var26 string
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(d.UserError)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 228, Col: 45}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if d.UserMsg != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "<div class=\"v-flash\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var27 string var templ_7745c5c3_Var27 string
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(d.UserMsg) templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(d.UserError)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 231, Col: 37} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 233, Col: 45}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
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, 62, "</div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "</div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "<table class=\"v-table mb-4\"><thead><tr><th>Username</th><th>Role</th><th>Created</th><th></th></tr></thead> <tbody>") if d.UserMsg != "" {
if templ_7745c5c3_Err != nil { templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "<div class=\"v-flash\">")
return templ_7745c5c3_Err
}
for _, u := range d.Users {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "<tr><td>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var28 string var templ_7745c5c3_Var28 string
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(u.Username) templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(d.UserMsg)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 238, Col: 24} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 236, Col: 37}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
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, 65, "</td><td class=\"v-muted\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "<table class=\"v-table mb-4\"><thead><tr><th>Username</th><th>Role</th><th>Created</th><th></th></tr></thead> <tbody>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, u := range d.Users {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "<tr><td>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var29 string var templ_7745c5c3_Var29 string
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(string(u.Role)) templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(u.Username)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 239, Col: 44} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 243, Col: 24}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
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, 66, "</td><td class=\"v-muted text-sm\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "</td><td class=\"v-muted\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var30 string var templ_7745c5c3_Var30 string
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(u.CreatedAt.Format("2006-01-02")) templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(string(u.Role))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 240, Col: 70} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 244, Col: 44}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
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, 67, "</td><td class=\"text-right\"><form class=\"inline\" method=\"post\" action=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "</td><td class=\"v-muted text-sm\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var31 templ.SafeURL var templ_7745c5c3_Var31 string
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/users/%d/reset-password", u.ID))) templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(u.CreatedAt.Format("2006-01-02"))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 242, Col: 113} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 245, Col: 70}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
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, 68, "\"><input type=\"hidden\" name=\"csrf_token\" value=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "</td><td class=\"text-right\"><form class=\"inline\" method=\"post\" action=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var32 string var templ_7745c5c3_Var32 templ.SafeURL
templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.CSRFToken) templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/users/%d/reset-password", u.ID)))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 243, Col: 68} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 247, Col: 113}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var32) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
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, 69, "\"> <input type=\"password\" class=\"v-input inline-block max-w-[140px]\" name=\"new_password\" placeholder=\"new password\"> <button class=\"v-btn-ghost\" type=\"submit\">Reset</button></form><form class=\"inline\" method=\"post\" action=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "\"><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_Var33 templ.SafeURL var templ_7745c5c3_Var33 string
templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/users/%d/delete", u.ID))) 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: 247, Col: 105}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "\" onsubmit=\"return confirm('Remove user?')\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var34 string
templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.CSRFToken)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 248, Col: 68} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 248, Col: 68}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var34) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var33)
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, 71, "\"> <button class=\"v-btn-ghost\" type=\"submit\">Remove</button></form></td></tr>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "\"> <input type=\"password\" class=\"v-input inline-block max-w-[140px]\" name=\"new_password\" placeholder=\"new password\"> <button class=\"v-btn-ghost\" type=\"submit\">Reset</button></form><form class=\"inline\" method=\"post\" action=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_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: 252, Col: 105}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "\" onsubmit=\"return confirm('Remove user?')\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var35 string
templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.CSRFToken)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 253, Col: 68}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var35)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "\"> <button class=\"v-btn-ghost\" type=\"submit\">Remove</button></form></td></tr>")
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, 72, "</tbody></table><form method=\"post\" action=\"/users\" class=\"grid md:grid-cols-4 gap-3 items-end\"><input type=\"hidden\" name=\"csrf_token\" value=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "</tbody></table><form method=\"post\" action=\"/users\" class=\"grid md:grid-cols-4 gap-3 items-end\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var35 string var templ_7745c5c3_Var36 string
templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.CSRFToken) templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.ResolveAttributeValue(d.CSRFToken)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 257, Col: 63} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/settings.templ`, Line: 262, Col: 63}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var35) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var36)
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, 73, "\"><div><label class=\"v-label\">Username</label> <input class=\"v-input\" name=\"username\"></div><div><label class=\"v-label\">Role</label> <select class=\"v-select\" name=\"role\"><option value=\"user\">user</option> <option value=\"admin\">admin</option></select></div><div><label class=\"v-label\">Initial Password</label> <input class=\"v-input\" type=\"password\" name=\"password\"></div><button class=\"v-btn\" type=\"submit\">Add User</button></form></section>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "\"><div><label class=\"v-label\">Username</label> <input class=\"v-input\" name=\"username\"></div><div><label class=\"v-label\">Role</label> <select class=\"v-select\" name=\"role\"><option value=\"user\">user</option> <option value=\"admin\">admin</option></select></div><div><label class=\"v-label\">Initial Password</label> <input class=\"v-input\" type=\"password\" name=\"password\"></div><button class=\"v-btn\" type=\"submit\">Add User</button></form></section>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "</div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "</div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -786,6 +814,13 @@ func currentWeeklyDigest(d SettingsData) bool {
return d.CurrentUser != nil && d.CurrentUser.EmailWeeklyDigest 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"
}
func Settings(d SettingsData) templ.Component { func Settings(d SettingsData) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
@@ -802,9 +837,9 @@ func Settings(d SettingsData) templ.Component {
}() }()
} }
ctx = templ.InitializeContext(ctx) ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var36 := templ.GetChildren(ctx) templ_7745c5c3_Var37 := templ.GetChildren(ctx)
if templ_7745c5c3_Var36 == nil { if templ_7745c5c3_Var37 == nil {
templ_7745c5c3_Var36 = templ.NopComponent templ_7745c5c3_Var37 = templ.NopComponent
} }
ctx = templ.ClearChildren(ctx) ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = Layout(d.Page, settingsBody(d)).Render(ctx, templ_7745c5c3_Buffer) templ_7745c5c3_Err = Layout(d.Page, settingsBody(d)).Render(ctx, templ_7745c5c3_Buffer)