panel-redesign: the 2.0 schema from zero - one baseline, no 1.x chain

Stage 1, step 1. BREAKING: a 1.x database is not migrated.

Schema
  - internal/store/migrations/ holds one file, 0001_init.sql, equal to the
    2.0 schema; 0002..0010 are deleted (they stay in history at 1.9.5). No
    admin table created and dropped, no ALTERs.
  - users.email replaces users.dmarc_report_email: the address is the
    user's own. The default DMARC report address is a user's setting
    (dmarc_default_mode hosted/account/custom/none, dmarc_default_address);
    the instance setting that mirrored the global user's field is gone.
  - domains.dmarc_rua_user_id: a domain follows a named user's default, or
    has an address of its own in dmarc_rua ('' = none). ON DELETE SET NULL
    leaves it at "no reports", never at someone else's default.
  - user_inbound_domains, users.all_domains, users.all_inbound_domains: in
    the schema for the inbound delegation of the next step.
  - users.role is 'global' / 'domain' (was 'domain_admin');
    rate_limits.allowed_ips, unused since 1.9.0, is dropped.

Opening a database
  - The baseline sets PRAGMA application_id ("SP20"). store.Open refuses a
    file that has a schema version without it (ErrForeignSchema) and one
    newer than the build. A 1.x file at user_version 10 would otherwise read
    as already migrated. The refused file is left untouched.

Code
  - Store.DomainDMARCRua is the one place a domain's report address is
    resolved; dnscheck.ResolveDMARCRua and Store.GlobalDMARCReportEmail are
    gone. dmarc.AllowedRecipients, the domain page, the dashboard and the
    DNS re-check go through it.
  - The old templates keep working on the new model until their pages are
    restyled: the Settings form's address field is the account e-mail and
    switches the user's default between it and none; a domain's "Same as
    Settings" makes it follow the user who chose it; a new or imported
    domain follows its creator.
  - Domain export/import: a domain that follows a user exports no address.

Tests: the baseline pinned table by table (TestBaselineSchema), refusal of a
1.x and of a newer database, constraints and cascades of the new columns and
table, resolution in every default mode, the deleted-user case, and the three
bridged handlers.

docs/schema-migrations.md is rewritten for the baseline; roadmap 11/27,
schema-squash marked built.

go test ./...   (Windows)
  ok for every package except two that fail the same way before this commit:
  internal/domain  TestWriteLoadPrivateKeyRoundtrip (file mode on NTFS)
  internal/logtail TestFollowTailsAndRotates (rename of an open file)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mix
2026-10-09 18:31:43 +03:00
co-authored by Claude Opus 5.5
parent 53c83f690c
commit 8d1af89e1a
42 changed files with 1284 additions and 599 deletions
+2 -2
View File
@@ -18,8 +18,8 @@ const (
type Role = store.Role
const (
RoleGlobal = store.RoleGlobal
RoleDomainAdmin = store.RoleDomainAdmin
RoleGlobal = store.RoleGlobal
RoleDomain = store.RoleDomain
)
// Principal is the authenticated panel user attached to a request.
+17
View File
@@ -3,6 +3,7 @@ package handlers
import (
"net/http"
"github.com/mixeme/selfpost/internal/dmarc"
"github.com/mixeme/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/web/auth"
)
@@ -35,6 +36,22 @@ func (h *Handlers) assignedDomains(p auth.Principal) ([]store.Domain, error) {
return h.store.ListDomainsForUser(p.ID)
}
// hostedDMARCAddress is the SelfPost-hosted report mailbox for a sending
// domain, "" when report ingest is off on this server.
func (h *Handlers) hostedDMARCAddress(domainName string) string {
if h.dmarc == nil || !h.cfg.DMARCEnabled {
return ""
}
return dmarc.HostedReportAddress(h.cfg.Hostname, domainName)
}
// domainReportAddress resolves where a domain's DMARC aggregate reports go:
// its own address, or the default of the user it follows. "" is a policy-only
// record.
func (h *Handlers) domainReportAddress(d store.Domain) (string, error) {
return h.store.DomainDMARCRua(d, h.hostedDMARCAddress(d.Name))
}
func domainNameSet(domains []store.Domain) map[string]bool {
m := make(map[string]bool, len(domains))
for _, d := range domains {
+27 -19
View File
@@ -153,29 +153,37 @@ func (h *Handlers) renderDomainDetail(w http.ResponseWriter, r *http.Request, st
// What DNS actually publishes for the domain today, checked against the key
// this server signs with. Cached by the checker, so re-rendering the page
// after a form post costs nothing.
profileEmail, err := h.store.GlobalDMARCReportEmail()
reportEmail, err := h.domainReportAddress(d)
if err != nil {
logf("panel: domain %d: global dmarc email: %v", d.ID, err)
logf("panel: domain %d: dmarc report address: %v", d.ID, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
reportEmail := dnscheck.ResolveDMARCRua(d.DMARCRua, profileEmail)
dns, srv := h.domainDNS(d, record, profileEmail, false)
dns, srv := h.domainDNS(d, record, reportEmail, false)
reportAuthName, reportAuthValue, needsReportAuth := dnscheck.ExternalReportAuth(d.Name, reportEmail)
hostedAddr := h.hostedDMARCAddress(d.Name)
// The form's "inherit" is a domain that follows a user's default. What it
// offers next to that choice is the address following would give: the
// followed user's when the domain already follows one, otherwise the
// signed-in user's own — choosing it makes the domain follow them.
dmarcMode := "inherit"
dmarcCustom := ""
hostedAddr := ""
if h.dmarc != nil && h.cfg.DMARCEnabled {
hostedAddr = dmarc.HostedReportAddress(h.cfg.Hostname, d.Name)
}
if d.DMARCRua.Valid {
if d.DMARCRua.String == "" {
profileEmail := reportEmail
if !d.DMARCRuaUserID.Valid {
profileEmail = ""
if p, ok := h.principal(r); ok {
if def, err := h.store.GetDMARCDefault(p.ID); err == nil {
profileEmail = def.Resolve(hostedAddr)
}
}
switch {
case d.DMARCRua == "":
dmarcMode = "none"
} else if hostedAddr != "" && strings.EqualFold(d.DMARCRua.String, hostedAddr) {
case hostedAddr != "" && strings.EqualFold(d.DMARCRua, hostedAddr):
dmarcMode = "hosted"
} else {
default:
dmarcMode = "custom"
dmarcCustom = d.DMARCRua.String
dmarcCustom = d.DMARCRua
}
}
dmarcSource := "policy"
@@ -186,7 +194,7 @@ func (h *Handlers) renderDomainDetail(w http.ResponseWriter, r *http.Request, st
dmarcSource = "custom"
case dmarcMode == "none":
dmarcSource = "none"
case profileEmail != "":
case reportEmail != "":
dmarcSource = "settings"
}
@@ -250,7 +258,7 @@ func (h *Handlers) renderDomainDetail(w http.ResponseWriter, r *http.Request, st
// and no extra environment variable is needed. That server result is returned
// alongside, because the page's suggested SPF record is built from the same
// addresses. force bypasses the cache, for the Re-check button.
func (h *Handlers) domainDNS(d store.Domain, record domain.DKIMRecord, profileEmail string, force bool) (dnscheck.Domain, dnscheck.Server) {
func (h *Handlers) domainDNS(d store.Domain, record domain.DKIMRecord, reportEmail string, force bool) (dnscheck.Domain, dnscheck.Server) {
srv := h.dns.Server(h.cfg.Hostname, false)
return h.dns.Domain(dnscheck.Query{
Name: d.Name,
@@ -258,7 +266,7 @@ func (h *Handlers) domainDNS(d store.Domain, record domain.DKIMRecord, profileEm
ExpectedDKIM: record.Value,
Hostname: srv.Hostname,
ServerIPs: srv.IPs,
DMARCReportEmail: dnscheck.ResolveDMARCRua(d.DMARCRua, profileEmail),
DMARCReportEmail: reportEmail,
}, force), srv
}
@@ -275,13 +283,13 @@ func (h *Handlers) HandleDomainDNSRecheck(w http.ResponseWriter, r *http.Request
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
profileEmail, err := h.store.GlobalDMARCReportEmail()
reportEmail, err := h.domainReportAddress(d)
if err != nil {
logf("panel: domain %d: global dmarc email: %v", d.ID, err)
logf("panel: domain %d: dmarc report address: %v", d.ID, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
h.domainDNS(d, record, profileEmail, true)
h.domainDNS(d, record, reportEmail, true)
http.Redirect(w, r, fmt.Sprintf("/domains/%d?rechecked=1", d.ID), http.StatusSeeOther)
}
+7
View File
@@ -277,6 +277,13 @@ func (h *Handlers) HandleImportDomain(w http.ResponseWriter, r *http.Request) {
h.renderBackupPage(w, r, status, msg)
return
}
// An export without an address of its own followed someone's default on the
// server it came from; here it follows whoever imports it.
if exp.DMARCRua == nil {
if p, ok := h.principal(r); ok {
h.followCreator(d.ID, p.ID)
}
}
http.Redirect(w, r, fmt.Sprintf("/domains/%d?imported=1", d.ID), http.StatusSeeOther)
}
+14 -8
View File
@@ -1,7 +1,6 @@
package handlers
import (
"database/sql"
"fmt"
"net/http"
"strings"
@@ -26,12 +25,14 @@ func (h *Handlers) HandleDomainDMARC(w http.ResponseWriter, r *http.Request) {
return
}
var rua sql.NullString
// "inherit" makes the domain follow the default of the user who chose it;
// every other mode gives the domain an address of its own ("" = none).
follow := false
rua := ""
switch strings.TrimSpace(r.PostFormValue("dmarc_rua_mode")) {
case "inherit":
rua = sql.NullString{}
follow = true
case "none":
rua = sql.NullString{Valid: true, String: ""}
case "custom":
email := strings.TrimSpace(r.PostFormValue("dmarc_rua_email"))
if err := validate.Email(email); err != nil {
@@ -42,20 +43,25 @@ func (h *Handlers) HandleDomainDMARC(w http.ResponseWriter, r *http.Request) {
h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{FormErr: "Enter a custom report address or choose another mode."})
return
}
rua = sql.NullString{Valid: true, String: email}
rua = email
case "hosted":
if h.dmarc == nil || !h.dmarc.Enabled() {
h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{FormErr: "SelfPost-hosted reports are not enabled on this server."})
return
}
addr := dmarc.HostedReportAddress(h.cfg.Hostname, d.Name)
rua = sql.NullString{Valid: true, String: addr}
rua = dmarc.HostedReportAddress(h.cfg.Hostname, d.Name)
default:
h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{FormErr: "Choose how aggregate reports are addressed for this domain."})
return
}
if err := h.store.UpdateDomainDMARCRua(d.ID, rua); err != nil {
var err error
if p, ok := h.principal(r); follow && ok {
err = h.store.SetDomainDMARCUser(d.ID, p.ID)
} else {
err = h.store.SetDomainDMARCAddress(d.ID, rua)
}
if err != nil {
logf("panel: domain %d: save dmarc rua: %v", d.ID, err)
h.renderDomainDetail(w, r, http.StatusInternalServerError, d, detailView{FormErr: "Could not save DMARC settings. Please check the logs and try again."})
return
@@ -0,0 +1,135 @@
package handlers
import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/mixeme/selfpost/internal/domain"
"github.com/mixeme/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/web/auth"
)
// postFormAs sends a form to a handler as the given principal.
func postFormAs(h http.HandlerFunc, p auth.Principal, target string, pathValues map[string]string, values url.Values) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodPost, target, strings.NewReader(values.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
for k, v := range pathValues {
req.SetPathValue(k, v)
}
rec := httptest.NewRecorder()
h(rec, auth.RequestWithPrincipal(req, p))
return rec
}
// The domain form's "Same as Settings" is, on the 2.0 schema, a domain that
// follows the default of the user who chose it; every other choice gives the
// domain an address of its own and stops it following anyone.
func TestDomainDMARCFollowsTheUserWhoChoseInherit(t *testing.T) {
h, _ := settingsServer(t)
h.domains = domain.NewService(h.store, nil, nil, "selfpost")
d, err := h.store.AddDomain("example.com", "selfpost")
if err != nil {
t.Fatalf("AddDomain: %v", err)
}
admin, err := h.store.GetUserByUsername("admin")
if err != nil {
t.Fatalf("GetUserByUsername: %v", err)
}
p := auth.Principal{ID: admin.ID, Username: "admin", Role: auth.RoleGlobal}
if err := h.store.UpdateUser(admin.ID, "admin", admin.PasswordHash, "mix@example.org"); err != nil {
t.Fatalf("UpdateUser: %v", err)
}
if err := h.store.SetDMARCDefault(admin.ID, store.DMARCDefaultAccount, ""); err != nil {
t.Fatalf("SetDMARCDefault: %v", err)
}
save := func(values url.Values) store.Domain {
t.Helper()
rec := postFormAs(h.HandleDomainDMARC, p, "/domains/1/dmarc", map[string]string{"id": "1"}, values)
if rec.Code != http.StatusSeeOther {
t.Fatalf("POST %v = %d, want 303:\n%s", values, rec.Code, rec.Body.String())
}
got, err := h.store.GetDomain(d.ID)
if err != nil {
t.Fatalf("GetDomain: %v", err)
}
return got
}
got := save(url.Values{"dmarc_rua_mode": {"inherit"}})
if !got.DMARCRuaUserID.Valid || got.DMARCRuaUserID.Int64 != admin.ID {
t.Fatalf("inherit: the domain follows %v, want user %d", got.DMARCRuaUserID, admin.ID)
}
if rua, err := h.domainReportAddress(got); err != nil || rua != "mix@example.org" {
t.Fatalf("inherit resolves to %q, %v; want the user's account e-mail", rua, err)
}
got = save(url.Values{"dmarc_rua_mode": {"custom"}, "dmarc_rua_email": {"dmarc@hub.example"}})
if got.DMARCRuaUserID.Valid || got.DMARCRua != "dmarc@hub.example" {
t.Fatalf("custom: %q / follows %v", got.DMARCRua, got.DMARCRuaUserID)
}
got = save(url.Values{"dmarc_rua_mode": {"none"}})
if got.DMARCRuaUserID.Valid || got.DMARCRua != "" {
t.Fatalf("none: %q / follows %v", got.DMARCRua, got.DMARCRuaUserID)
}
}
// Until the Account page replaces the old Settings form, its single address
// field is the account e-mail and switches the user's DMARC default between
// "my account e-mail" and "none". Nothing is written to the instance settings.
func TestSettingsFormMapsToAccountEmailAndDefault(t *testing.T) {
h, password := settingsServer(t)
submit := func(email string) store.User {
t.Helper()
rec := postFormAs(h.HandleSettings, globalPrincipal, "/settings", nil, url.Values{
"username": {"admin"}, "current_password": {password}, "dmarc_report_email": {email},
})
if rec.Code != http.StatusSeeOther {
t.Fatalf("POST /settings (%q) = %d, want 303:\n%s", email, rec.Code, rec.Body.String())
}
u, err := h.store.GetUser(globalPrincipal.ID)
if err != nil {
t.Fatalf("GetUser: %v", err)
}
return u
}
u := submit("mix@example.org")
if u.Email != "mix@example.org" || u.DMARCDefaultMode != store.DMARCDefaultAccount {
t.Fatalf("after setting the address: e-mail %q, default %q", u.Email, u.DMARCDefaultMode)
}
if v, err := h.store.GetSetting("dmarc_report_email"); err == nil && v != "" {
t.Fatalf("the address was mirrored into the instance settings: %q", v)
}
u = submit("")
if u.Email != "" || u.DMARCDefaultMode != store.DMARCDefaultNone {
t.Fatalf("after clearing the address: e-mail %q, default %q", u.Email, u.DMARCDefaultMode)
}
}
// A new domain takes its report address from whoever created it.
func TestFollowCreator(t *testing.T) {
h, _ := settingsServer(t)
d, err := h.store.AddDomain("example.com", "selfpost")
if err != nil {
t.Fatalf("AddDomain: %v", err)
}
h.followCreator(d.ID, globalPrincipal.ID)
got, err := h.store.GetDomain(d.ID)
if err != nil {
t.Fatalf("GetDomain: %v", err)
}
if !got.DMARCRuaUserID.Valid || got.DMARCRuaUserID.Int64 != globalPrincipal.ID {
t.Fatalf("the domain follows %v, want its creator (user %d)", got.DMARCRuaUserID, globalPrincipal.ID)
}
// The creator's default is "none" until they choose one, so the new
// domain publishes a policy-only record — as a fresh 1.x install did.
if rua, err := h.domainReportAddress(got); err != nil || rua != "" {
t.Fatalf("resolved %q, %v; want no report address yet", rua, err)
}
}
+25 -6
View File
@@ -51,10 +51,6 @@ func (h *Handlers) renderDashboard(w http.ResponseWriter, r *http.Request, statu
}
func (h *Handlers) domainRows(domains []store.Domain) []domainRow {
profileEmail := ""
if email, err := h.store.GlobalDMARCReportEmail(); err == nil {
profileEmail = email
}
rows := make([]domainRow, len(domains))
var wg sync.WaitGroup
for i, d := range domains {
@@ -67,7 +63,12 @@ func (h *Handlers) domainRows(domains []store.Domain) []domainRow {
logf("panel: dashboard: domain %d: dkim record: %v", d.ID, err)
return
}
dns, _ := h.domainDNS(d, record, profileEmail, false)
reportEmail, err := h.domainReportAddress(d)
if err != nil {
logf("panel: dashboard: domain %d: dmarc report address: %v", d.ID, err)
return
}
dns, _ := h.domainDNS(d, record, reportEmail, false)
rows[i].DNS = dns.Overall
}()
}
@@ -86,7 +87,8 @@ func dashboardFlash(r *http.Request) string {
// OpenDKIM reload), and redirects to the domain's page so the DNS record to
// publish is shown (product.md).
func (h *Handlers) HandleAddDomain(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requireGlobal(w, r); !ok {
p, ok := h.requireGlobal(w, r)
if !ok {
return
}
if err := r.ParseForm(); err != nil {
@@ -111,9 +113,26 @@ func (h *Handlers) HandleAddDomain(w http.ResponseWriter, r *http.Request) {
"Could not add the domain. Please check the logs and try again.", raw)
return
}
h.followCreator(d.ID, p.ID)
http.Redirect(w, r, fmt.Sprintf("/domains/%d", d.ID), http.StatusSeeOther)
}
// followCreator makes a new domain take its DMARC report address from the
// default of whoever created it (plan § Account e-mail). The domain exists
// either way: a failure here leaves it with no report address, which its page
// shows and its DMARC form can change.
func (h *Handlers) followCreator(domainID, userID int64) {
if err := h.store.SetDomainDMARCUser(domainID, userID); err != nil {
logf("panel: domain %d: follow user %d's dmarc default: %v", domainID, userID, err)
return
}
if h.dmarc != nil && h.dmarc.Enabled() {
if err := h.dmarc.Resync(); err != nil {
logf("panel: domain %d: dmarc resync: %v", domainID, err)
}
}
}
// HandleDeleteConfirm shows the cascade warning before a domain is removed.
func (h *Handlers) HandleDeleteConfirm(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requireGlobal(w, r); !ok {
@@ -373,7 +373,7 @@ func domainAdmin(t *testing.T, st *store.Store, username string, domainIDs ...in
if err != nil {
t.Fatalf("add placeholder domain: %v", err)
}
id, err := st.CreateUser(username, hash, store.RoleDomainAdmin, []int64{placeholder.ID})
id, err := st.CreateUser(username, hash, store.RoleDomain, []int64{placeholder.ID})
if err != nil {
t.Fatalf("create domain admin %s: %v", username, err)
}
@@ -387,7 +387,7 @@ func domainAdmin(t *testing.T, st *store.Store, username string, domainIDs ...in
}
return auth.Principal{ID: u.ID, Username: u.Username, Role: u.Role, Domains: u.DomainIDs}
}
id, err := st.CreateUser(username, hash, store.RoleDomainAdmin, domainIDs)
id, err := st.CreateUser(username, hash, store.RoleDomain, domainIDs)
if err != nil {
t.Fatalf("create domain admin %s: %v", username, err)
}
+20 -3
View File
@@ -30,7 +30,7 @@ func (h *Handlers) HandleSettings(w http.ResponseWriter, r *http.Request) {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
h.renderSettings(w, r, http.StatusOK, "", u.Username, u.DMARCReportEmail, h.sendLogRetentionDays(), p.IsGlobal())
h.renderSettings(w, r, http.StatusOK, "", u.Username, u.Email, h.sendLogRetentionDays(), p.IsGlobal())
case http.MethodPost:
h.submitSettings(w, r)
default:
@@ -128,7 +128,7 @@ func (h *Handlers) submitSettings(w http.ResponseWriter, r *http.Request) {
dmarcEmail := strings.TrimSpace(r.PostFormValue("dmarc_report_email"))
formRetention := currentRetention
if !p.IsGlobal() {
dmarcEmail = user.DMARCReportEmail
dmarcEmail = user.Email
} else if raw := strings.TrimSpace(r.PostFormValue("send_log_retention_days")); raw != "" {
parsed, err := strconv.Atoi(raw)
if err != nil {
@@ -161,7 +161,7 @@ func (h *Handlers) submitSettings(w http.ResponseWriter, r *http.Request) {
}
}
emailChanging := p.IsGlobal() && dmarcEmail != user.DMARCReportEmail
emailChanging := p.IsGlobal() && dmarcEmail != user.Email
retentionChanging := false
if p.IsGlobal() && formRetention != currentRetention {
@@ -218,6 +218,23 @@ func (h *Handlers) submitSettings(w http.ResponseWriter, r *http.Request) {
}
}
// Until the Account page replaces this form (stage 2), its one "default
// report address" field stands for both halves of the 2.0 model: it is the
// account e-mail, and the user's DMARC default is "my account e-mail" while
// it is filled and "none" once it is cleared.
if emailChanging {
mode := store.DMARCDefaultNone
if dmarcEmail != "" {
mode = store.DMARCDefaultAccount
}
if err := h.store.SetDMARCDefault(user.ID, mode, ""); err != nil {
logf("panel: settings: set dmarc default failed: %v", err)
h.renderSettings(w, r, http.StatusInternalServerError,
"Could not save the changes. Please check the logs and try again.", username, dmarcEmail, formRetention, p.IsGlobal())
return
}
}
if retentionChanging {
if err := h.store.SetSendLogRetentionDays(formRetention); err != nil {
logf("panel: settings: set send-log retention failed: %v", err)
+8 -8
View File
@@ -58,7 +58,7 @@ func (h *Handlers) HandleUserNew(w http.ResponseWriter, r *http.Request) {
}
switch r.Method {
case http.MethodGet:
h.renderUserForm(w, r, http.StatusOK, 0, userFormView{FormRole: string(store.RoleDomainAdmin)})
h.renderUserForm(w, r, http.StatusOK, 0, userFormView{FormRole: string(store.RoleDomain)})
case http.MethodPost:
h.submitUserCreate(w, r)
default:
@@ -162,11 +162,11 @@ func (h *Handlers) submitUserCreate(w http.ResponseWriter, r *http.Request) {
h.renderUserForm(w, r, http.StatusBadRequest, 0, userFormView{FormErr: err.Error(), FormUsername: username, FormRole: string(role), FormDomains: domainIDSetFromForm(r)})
return
}
if role != store.RoleGlobal && role != store.RoleDomainAdmin {
if role != store.RoleGlobal && role != store.RoleDomain {
h.renderUserForm(w, r, http.StatusBadRequest, 0, userFormView{FormErr: "Choose a valid role.", FormUsername: username, FormRole: string(role), FormDomains: domainIDSetFromForm(r)})
return
}
if role == store.RoleDomainAdmin && len(domainIDs) == 0 {
if role == store.RoleDomain && len(domainIDs) == 0 {
h.renderUserForm(w, r, http.StatusBadRequest, 0, userFormView{FormErr: "Select at least one domain for a domain administrator.", FormUsername: username, FormRole: string(role), FormDomains: domainIDSetFromForm(r)})
return
}
@@ -213,16 +213,16 @@ func (h *Handlers) submitUserUpdate(w http.ResponseWriter, r *http.Request, u st
h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: err.Error(), FormUsername: username, FormRole: string(role), FormDomains: selected})
return
}
if role != store.RoleGlobal && role != store.RoleDomainAdmin {
if role != store.RoleGlobal && role != store.RoleDomain {
h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: "Choose a valid role.", FormUsername: username, FormRole: string(role), FormDomains: selected})
return
}
if role == store.RoleDomainAdmin && len(domainIDs) == 0 {
if role == store.RoleDomain && len(domainIDs) == 0 {
h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: "Select at least one domain for a domain administrator.", FormUsername: username, FormRole: string(role), FormDomains: selected})
return
}
if u.Role == store.RoleGlobal && role == store.RoleDomainAdmin {
if u.Role == store.RoleGlobal && role == store.RoleDomain {
n, err := h.store.CountGlobalUsers()
if err != nil || n <= 1 {
msg := "Cannot demote the last global administrator."
@@ -249,7 +249,7 @@ func (h *Handlers) submitUserUpdate(w http.ResponseWriter, r *http.Request, u st
hash = string(newHash)
}
if err := h.store.UpdateUser(u.ID, username, hash, u.DMARCReportEmail); err != nil {
if err := h.store.UpdateUser(u.ID, username, hash, u.Email); err != nil {
if errors.Is(err, store.ErrUserExists) {
h.renderUserForm(w, r, http.StatusConflict, u.ID, userFormView{FormErr: "That username is already in use.", FormUsername: username, FormRole: string(role), FormDomains: selected})
return
@@ -272,7 +272,7 @@ func (h *Handlers) submitUserUpdate(w http.ResponseWriter, r *http.Request, u st
}
}
if role == store.RoleDomainAdmin {
if role == store.RoleDomain {
if err := h.store.SetUserDomains(u.ID, domainIDs); err != nil {
logf("panel: set user domains: %v", err)
h.renderUserForm(w, r, http.StatusInternalServerError, u.ID, userFormView{FormErr: "Could not save domain assignments.", FormUsername: username, FormRole: string(role), FormDomains: selected})
+1 -1
View File
@@ -16,7 +16,7 @@
<label for="role">Role</label>
{{if .LastGlobalLocked}}<input type="hidden" name="role" value="{{.FormRole}}">{{end}}
<select id="role" name="role" data-global-role="{{.GlobalRole}}"{{if .LastGlobalLocked}} disabled{{end}}>
<option value="domain_admin" {{if eq .FormRole "domain_admin"}}selected{{end}}>Domain administrator</option>
<option value="domain" {{if eq .FormRole "domain"}}selected{{end}}>Domain administrator</option>
<option value="global" {{if eq .FormRole "global"}}selected{{end}}>Global administrator</option>
</select>
{{if .LastGlobalLocked}}<p class="muted">The only global administrator cannot be demoted.</p>{{end}}