panel-redesign: Server pages and Help on the kit; the help drawer is gone

Stage 2, step 6. Written by Sonnet, reviewed by Opus.

  - Six pages named after their mockups: system-log, backup, users,
    user, user-delete, help, with typed page data (view/server.go,
    help.go), fixtures and tests. The old templates of the six are gone
    (user_form.html, encrypt_fields.html deleted, the others rewritten)
    and their names are off legacy_pages.txt, which now holds mail_queue
    alone.
  - Help is six help_topic blocks (checks, certificate, rdns, dns, apps,
    limits); a test renders every fixture page and fails on a Help link
    without a topic. The help drawer (help_drawer.html and its includes
    in layout_legacy.html) is removed; its rules in legacy.css go with
    the next step.
  - The user form sets the user's e-mail (plan, Account e-mail): create
    and update, validated, and a changed address resyncs DMARC for a
    user whose default is the account e-mail - through the helper the
    Account profile now shares.
  - Handlers pass typed data; routes, POST targets, field names,
    validation and authorization are unchanged. A test loads the user
    form for four reach combinations and for the only global user, posts
    it back untouched and finds the user unchanged.

Fixed on the way
  - The user delete confirmation said the domains were not affected. A
    domain that follows the user's default report address is left with
    none; the page names those domains now.
  - A refused backup lost the encryption choice and printed a literal
    <code> element in its message.

Differences from the mockups, for the owner:
  - Help has no DMARC and no Inbound topic: its outline fixes two boxes
    of three topics, although other mockups link to #dmarc and #inbound.
    Until the owner decides, Account's Help link goes to #dns and the
    inbound MX box has none;
  - system log: newest line first, as drawn; "refreshes on its own" (5 s
    active, 30 s idle); only status=, warning: and error: lines are
    coloured;
  - backup: the file field is a plain file input; the encryption box is
    ticked, as drawn and as on the domain export, so a plain download
    means unticking it first;
  - users: an E-mail column; the flash does not name the user;
  - user form: no lead; the domain lists are no longer hidden by script
    when Global is chosen (the server ignores them for that role).

Evidence
  - docs/assets/panel-redesign/evidence/system-log.png, backup.png,
    users.png, user.png, user-delete.png, help.png: 1440 px wide,
    headless Edge, real templates served locally with their fixtures -
    NOT from the container (no Docker on this machine).
  - panel/audit.js on the six pages at 1440 and 1280: 720 texts checked,
    problems: [].
  - The system log's polling swap was not exercised in a browser; it
    uses the mechanism of Overview and Health.
  - go test -count=1 ./internal/web/... ./cmd/panel
      ok  internal/web
      ok  internal/web/auth
      ok  internal/web/handlers
      ok  internal/web/validate
      ok  internal/web/view
      ok  cmd/panel

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mix
2026-10-10 02:14:25 +03:00
co-authored by Claude Opus 5.5
parent 610023ef6a
commit fa696efa8b
37 changed files with 1896 additions and 537 deletions
+11 -3
View File
@@ -162,9 +162,7 @@ func (h *Handlers) HandleAccountProfile(w http.ResponseWriter, r *http.Request)
}
// The e-mail is the report address of every domain that follows this
// user's "my account e-mail" default.
if f.Email != u.Email && u.DMARCDefaultMode == store.DMARCDefaultAccount {
h.resyncDMARC("account e-mail")
}
h.resyncAfterEmailChange(u, f.Email)
logf("panel: user %d profile updated (username: %t, e-mail: %t)", u.ID, f.Username != u.Username, f.Email != u.Email)
http.Redirect(w, r, "/account?done=profile", http.StatusSeeOther)
}
@@ -276,6 +274,16 @@ func (h *Handlers) HandleAccountDMARC(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/account?done=dmarc", http.StatusSeeOther)
}
// resyncAfterEmailChange is what changing a user's account e-mail does for
// DMARC: the address is the report address of every domain that follows a user
// whose default is "my account e-mail", so the ingest allow-list is rebuilt. It
// is the one path both the Account page and the user form take.
func (h *Handlers) resyncAfterEmailChange(u store.User, newEmail string) {
if newEmail != u.Email && u.DMARCDefaultMode == store.DMARCDefaultAccount {
h.resyncDMARC("account e-mail")
}
}
// resyncDMARC rebuilds the ingest allow-list after something that changes
// where a domain's reports go. A failure is logged, not shown: the setting is
// saved, and the next resync picks it up.
+7 -8
View File
@@ -15,6 +15,7 @@ import (
"github.com/mixeme/selfpost/internal/secretfile"
"github.com/mixeme/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/web/validate"
"github.com/mixeme/selfpost/internal/web/view"
)
// maxImportBytes caps a domain-import upload. A domain export is a small JSON
@@ -45,13 +46,11 @@ func (h *Handlers) renderBackupPage(w http.ResponseWriter, r *http.Request, stat
// password), importErr to the import card, so neither message appears under the
// wrong form.
func (h *Handlers) renderBackupPageWith(w http.ResponseWriter, r *http.Request, status int, importErr, backupErr string) {
data := h.pageBase(r)
data["Title"] = "SelfPost — backup & migration"
data["Active"] = "backup"
data["ImportErr"] = importErr
data["BackupErr"] = backupErr
data["MinPwLen"] = validate.MinSecretFilePasswordLen
h.view.Render(w, status, "backup", data)
refusal := importErr
if refusal == "" {
refusal = backupErr
}
h.view.Render(w, status, "backup", view.NewBackup(h.shellMeta(r), validate.MinSecretFilePasswordLen, refusal))
}
// HandleBackup streams a full-server backup as a download (architecture.md §
@@ -327,7 +326,7 @@ func decryptErrorMessage(err error) string {
// deployBackupErr phrases a pre-flight backup failure for the operator.
func deployBackupErr(err error) string {
if strings.Contains(err.Error(), "DeployRoot") || strings.Contains(err.Error(), "deploy root") {
return "Full backup needs the project directory mounted read-only at /selfpost-deploy — add <code>.:/selfpost-deploy:ro</code> to docker-compose.yml and recreate the container."
return "Full backup needs the project directory mounted read-only at /selfpost-deploy — add .:/selfpost-deploy:ro to the volumes in docker-compose.yml and recreate the container."
}
return "Could not create the backup: " + err.Error()
}
+4 -8
View File
@@ -3,20 +3,16 @@ package handlers
import (
"net/http"
"github.com/mixeme/selfpost/internal/web/auth"
"github.com/mixeme/selfpost/internal/web/view"
)
// HandleHelp renders the Help page. It is for both roles; the topics about the
// checks of Overview and Health are left out for a viewer who cannot see them.
func (h *Handlers) HandleHelp(w http.ResponseWriter, r *http.Request) {
p, ok := h.principal(r)
if !ok {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
data := map[string]any{
"Title": "SelfPost — help",
"User": auth.CurrentUser(r),
"Active": "help",
"IsGlobal": p.IsGlobal(),
}
h.view.Render(w, http.StatusOK, "help", data)
h.view.Render(w, http.StatusOK, "help", view.NewHelp(h.shellMeta(r), p.IsGlobal()))
}
+16 -14
View File
@@ -385,15 +385,7 @@ func (h *Handlers) HandleSystemLog(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requireGlobal(w, r); !ok {
return
}
lines, errText := h.readLogTail()
h.view.Render(w, http.StatusOK, "system_log", map[string]any{
"Title": "SelfPost — system log",
"User": auth.CurrentUser(r),
"Active": "system_log",
"IsGlobal": true,
"Lines": lines,
"Error": errText,
})
h.view.Render(w, http.StatusOK, "system-log", h.systemLogPage(r))
}
// HandleSystemLogBody serves the HTMX polling fragment for the log-tail view.
@@ -401,11 +393,21 @@ func (h *Handlers) HandleSystemLogBody(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requireGlobal(w, r); !ok {
return
}
lines, errText := h.readLogTail()
h.view.RenderFragment(w, http.StatusOK, "system_log_body", map[string]any{
"Lines": lines,
"Error": errText,
})
h.view.RenderFragment(w, http.StatusOK, "system_log_body", h.systemLogPage(r))
}
// systemLogPage reads the tail of mail.log and splits each line into the time
// and the text the page shows it in, as the delivery log of one message does. A
// line whose head is not a timestamp the log format recognises keeps its whole
// text and no time: nothing is dropped from what the log says.
func (h *Handlers) systemLogPage(r *http.Request) *view.SystemLog {
raw, errText := h.readLogTail()
lines := make([]view.LogLine, len(raw))
for i, line := range raw {
stamp, rest := logtail.SplitTimestamp(line)
lines[i] = view.LogLine{Time: stamp, Text: rest, Level: view.LogLineLevel(rest)}
}
return view.NewSystemLog(h.shellMeta(r), lines, errText)
}
func (h *Handlers) readLogTail() ([]string, string) {
@@ -0,0 +1,191 @@
package handlers
import (
"net/http"
"net/url"
"strings"
"testing"
"github.com/mixeme/selfpost/internal/dmarc"
"github.com/mixeme/selfpost/internal/postfix"
"github.com/mixeme/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/web/auth"
)
// The user form sets the account e-mail: create stores it, an edit changes it,
// clearing the field clears it, and a post that does not mention the field at
// all leaves it alone.
func TestUserFormSetsTheAccountEmail(t *testing.T) {
h, st := inboundHandlers(t)
out1, _ := st.AddDomain("one.example.com", "mail")
root := userWith(t, st, "root", store.RoleGlobal, store.Reach{})
rec := send(h.HandleUserNew, &root, "POST", "/server/users/new", nil, url.Values{
"username": {"ops"}, "email": {" ops@acme.io "}, "password": {"a-long-enough-password-1"}, "role": {"domain"},
"domain_ids": {idStr(out1.ID)},
})
if rec.Code != http.StatusSeeOther {
t.Fatalf("create with an e-mail = %d:\n%s", rec.Code, rec.Body.String())
}
u, _ := st.GetUserByUsername("ops")
if u.Email != "ops@acme.io" || len(u.DomainIDs) != 1 {
t.Fatalf("created user = %+v", u)
}
path, pv := "/server/users/"+idStr(u.ID), map[string]string{"uid": idStr(u.ID)}
post := func(extra url.Values) store.User {
t.Helper()
form := url.Values{"username": {"ops"}, "role": {"domain"}, "domain_ids": {idStr(out1.ID)}}
for k, v := range extra {
form[k] = v
}
if rec := send(h.HandleUserEdit, &root, "POST", path, pv, form); rec.Code != http.StatusSeeOther {
t.Fatalf("POST = %d:\n%s", rec.Code, rec.Body.String())
}
got, _ := st.GetUser(u.ID)
return got
}
if got := post(url.Values{"email": {"new@acme.io"}}); got.Email != "new@acme.io" {
t.Errorf("changed e-mail = %q", got.Email)
}
if got := post(nil); got.Email != "new@acme.io" {
t.Errorf("a post without the field changed the e-mail to %q", got.Email)
}
if got := post(url.Values{"email": {""}}); got.Email != "" {
t.Errorf("clearing the field left %q", got.Email)
}
// Creating without one is fine.
rec = send(h.HandleUserNew, &root, "POST", "/server/users/new", nil, url.Values{
"username": {"plain"}, "password": {"a-long-enough-password-1"}, "role": {"global"}})
if plain, _ := st.GetUserByUsername("plain"); rec.Code != http.StatusSeeOther || plain.Email != "" {
t.Errorf("create without an e-mail = %d, %q", rec.Code, plain.Email)
}
}
// A bad address is refused on both forms: nothing is saved, and the form comes
// back with what was typed, the refusal as the flash.
func TestUserFormRefusesABadEmailAndKeepsWhatWasTyped(t *testing.T) {
h, st := inboundHandlers(t)
out1, _ := st.AddDomain("one.example.com", "mail")
root := userWith(t, st, "root", store.RoleGlobal, store.Reach{})
u := userWith(t, st, "ops", store.RoleDomain, store.Reach{DomainIDs: []int64{out1.ID}})
if err := st.UpdateUser(u.ID, "ops", "test-hash", "ops@acme.io"); err != nil {
t.Fatal(err)
}
rec := send(h.HandleUserEdit, &root, "POST", "/server/users/"+idStr(u.ID), map[string]string{"uid": idStr(u.ID)},
url.Values{"username": {"ops"}, "email": {"not-an-address"}, "role": {"domain"}, "domain_ids": {idStr(out1.ID)}})
body := rec.Body.String()
for _, want := range []string{`class="notification is-danger is-light"`, "enter a valid email address",
`name="email" type="email" value="not-an-address"`, `<h1 class="title is-3">ops</h1>`} {
if !strings.Contains(body, want) {
t.Errorf("the refused edit form is missing %q", want)
}
}
if after, _ := st.GetUser(u.ID); rec.Code != http.StatusBadRequest || after.Email != "ops@acme.io" {
t.Errorf("a bad address on edit = %d, stored e-mail %q", rec.Code, after.Email)
}
rec = send(h.HandleUserNew, &root, "POST", "/server/users/new", nil, url.Values{
"username": {"fresh"}, "email": {"nobody@"}, "password": {"a-long-enough-password-1"}, "role": {"global"}})
body = rec.Body.String()
for _, want := range []string{"enter a valid email address", `name="email" type="email" value="nobody@"`, `name="username" value="fresh"`,
`<h1 class="title is-3">Create user</h1>`} {
if !strings.Contains(body, want) {
t.Errorf("the refused create form is missing %q", want)
}
}
if fresh, _ := st.GetUserByUsername("fresh"); rec.Code != http.StatusBadRequest || fresh.ID != 0 {
t.Errorf("a bad address on create = %d, user created: %v", rec.Code, fresh.ID != 0)
}
}
type countingMaps struct{ n int }
func (c *countingMaps) RebuildDMARCMaps(postfix.DMARCMapsConfig) error { c.n++; return nil }
// Changing the e-mail of a user whose default report address is "my account
// e-mail" rebuilds the ingest allow-list, exactly as the Account page does; for
// anyone else, or when the address is not changed, nothing is rebuilt.
func TestUserFormEmailChangeResyncsDMARCOnlyForTheAccountDefault(t *testing.T) {
h, st := inboundHandlers(t)
maps := &countingMaps{}
h.dmarc = dmarc.NewService(st, maps, "mail.example.org", true)
root := userWith(t, st, "root", store.RoleGlobal, store.Reach{})
follower := userWith(t, st, "follower", store.RoleGlobal, store.Reach{})
other := userWith(t, st, "other", store.RoleGlobal, store.Reach{})
if err := st.SetDMARCDefault(follower.ID, store.DMARCDefaultAccount, ""); err != nil {
t.Fatal(err)
}
save := func(who auth.Principal, email string) {
t.Helper()
rec := send(h.HandleUserEdit, &root, "POST", "/server/users/"+idStr(who.ID), map[string]string{"uid": idStr(who.ID)},
url.Values{"username": {who.Username}, "email": {email}, "role": {"global"}})
if rec.Code != http.StatusSeeOther {
t.Fatalf("save %s = %d:\n%s", who.Username, rec.Code, rec.Body.String())
}
}
save(other, "other@example.org")
if maps.n != 0 {
t.Errorf("a user whose default is not the account e-mail rebuilt the maps %d time(s)", maps.n)
}
save(follower, "follower@example.org")
if maps.n != 1 {
t.Errorf("changing the account e-mail of a follower rebuilt the maps %d time(s), want 1", maps.n)
}
save(follower, "follower@example.org")
if maps.n != 1 {
t.Errorf("saving the same address again rebuilt the maps (%d)", maps.n)
}
}
// Deleting a user leaves every domain that follows their default report address
// with none; the confirmation says which, and says nothing of the kind when no
// domain follows.
func TestUserDeleteConfirmationNamesTheDomainsThatLoseTheirReportAddress(t *testing.T) {
h, st := inboundHandlers(t)
root := userWith(t, st, "root", store.RoleGlobal, store.Reach{})
u := userWith(t, st, "shop", store.RoleGlobal, store.Reach{})
path, pv := "/server/users/"+idStr(u.ID)+"/delete", map[string]string{"uid": idStr(u.ID)}
page := func() string { return send(h.HandleUserDeleteConfirm, &root, "GET", path, pv, nil).Body.String() }
if body := page(); strings.Contains(body, "DMARC report address") {
t.Errorf("no domain follows the user, yet the page speaks of DMARC:\n%s", body)
}
var follows []store.Domain
for _, name := range []string{"a.example.com", "b.example.com", "c.example.com", "d.example.com"} {
d, _ := st.AddDomain(name, "mail")
if err := st.SetDomainDMARCUser(d.ID, u.ID); err != nil {
t.Fatal(err)
}
follows = append(follows, d)
}
elsewhere, _ := st.AddDomain("z.example.com", "mail")
if err := st.SetDomainDMARCUser(elsewhere.ID, root.ID); err != nil {
t.Fatal(err)
}
body := page()
want := `<strong>a.example.com</strong>, <strong>b.example.com</strong>, <strong>c.example.com</strong> and 1 more will have no DMARC report address until someone sets one.`
if !strings.Contains(body, want) {
t.Errorf("the delete page is missing %q:\n%s", want, body)
}
if strings.Contains(body, "z.example.com") || strings.Contains(body, "d.example.com") {
t.Error("the page names a domain that does not follow this user, or spells out a fourth name")
}
// What it says is what happens: the follower domains end up with no address.
if rec := send(h.HandleUserDelete, &root, "POST", path, pv, url.Values{}); rec.Code != http.StatusSeeOther {
t.Fatalf("delete = %d", rec.Code)
}
for _, d := range follows {
got, _ := st.GetDomain(d.ID)
rua, err := st.DomainDMARCRua(got, "")
if err != nil || rua != "" {
t.Errorf("%s still reports to %q (%v) after its user was deleted", d.Name, rua, err)
}
}
}
@@ -0,0 +1,363 @@
package handlers
import (
"html"
"net/http"
"net/url"
"regexp"
"strings"
"testing"
"github.com/mixeme/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/web/auth"
)
// ---- System log
// The page and the fragment polled from it show the tail of mail.log, newest
// line first, with the time split off each line.
func TestSystemLogPageAndFragmentShowTheTail(t *testing.T) {
h := &Handlers{view: mustView(t), cfg: Config{Version: "test"}}
h.cfg.MailLogPath = writeMailLog(t,
"Sep 21 13:58:09 mail postfix/smtp[2123]: 4XcB7k2Jm9z1: to=<noc@example.org>, status=deferred (451 Greylisted)",
"Sep 21 14:01:52 mail postfix/qmgr[58]: 4XcB9q0Lw2z1: removed",
"a line the log format does not stamp",
)
page := getBody(t, h.HandleSystemLog, "/server/log")
for _, want := range []string{
`<h1 class="title is-3">System log</h1>`, `hx-get="/server/log/fragment"`,
`<span class="sp-d">Sep 21 14:01:52</span> mail postfix/qmgr[58]: 4XcB9q0Lw2z1: removed`,
`<span class="sp-w">mail postfix/smtp[2123]`, // a deferral is amber
`a line the log format does not stamp`,
} {
if !strings.Contains(page, want) {
t.Errorf("the system log page is missing %q", want)
}
}
if strings.Index(page, "a line the log format") > strings.Index(page, "postfix/qmgr") ||
strings.Index(page, "postfix/qmgr") > strings.Index(page, "postfix/smtp[2123]") {
t.Error("the log is not shown newest line first")
}
frag := getBody(t, h.HandleSystemLogBody, "/server/log/fragment")
if !strings.HasPrefix(frag, `<div class="box" id="system-log" data-poll`) || strings.Contains(frag, "<html") {
t.Errorf("the fragment is not the polled box alone:\n%.300s", frag)
}
if !strings.Contains(frag, "postfix/qmgr[58]") {
t.Error("the fragment does not carry the log")
}
}
// A log that is not there yet (rotation is renaming it) is the ordinary empty
// state, not a fault. (The page of a log that cannot be read is in the view's
// tests: a read error cannot be made the same way on every platform.)
func TestSystemLogMissingIsTheEmptyState(t *testing.T) {
h := &Handlers{view: mustView(t), cfg: Config{Version: "test"}}
h.cfg.MailLogPath = t.TempDir() + "/never-created.log"
if out := getBody(t, h.HandleSystemLog, "/server/log"); !strings.Contains(out, "No log lines yet.") || strings.Contains(out, "Could not read") {
t.Errorf("a missing log should be the empty state:\n%s", out)
}
}
// ---- Users
func TestUsersPageListsEveryoneWithTheirReach(t *testing.T) {
h, st := inboundHandlers(t)
out1, _ := st.AddDomain("out.example.com", "mail")
in1, _ := st.AddInboundDomain("in.example.com")
root := userWith(t, st, "root", store.RoleGlobal, store.Reach{})
userWith(t, st, "shop", store.RoleDomain, store.Reach{DomainIDs: []int64{out1.ID}, InboundDomainIDs: []int64{in1.ID}})
userWith(t, st, "everything", store.RoleDomain, store.Reach{AllDomains: true})
if err := st.UpdateUser(root.ID, "root", "test-hash", "root@example.org"); err != nil {
t.Fatal(err)
}
body := send(h.HandleUsers, &root, "GET", "/server/users?done=updated", nil, nil).Body.String()
for _, want := range []string{
`<strong>root</strong> <span class="tag is-light">you</span>`, `root@<wbr>example.org`,
`<span class="tag is-primary is-light">global</span>`, `<span class="tag is-light">domain</span>`,
`<td class="sp-muted">out.example.com</td><td class="sp-muted">in.example.com</td>`,
`<td class="sp-muted">All</td><td class="sp-muted">—</td>`,
`User updated.`, `3 users`, `href="/server/users/new"`, `<th>Inbound domains</th>`,
} {
if !strings.Contains(body, want) {
t.Errorf("the users page is missing %q", want)
}
}
h.cfg.InboundEnabled = false
if body := send(h.HandleUsers, &root, "GET", "/server/users", nil, nil).Body.String(); strings.Contains(body, "<th>Inbound domains</th>") {
t.Error("the users list has an inbound column while the inbound feature is off")
}
}
// ---- The user form
var (
tagRe = regexp.MustCompile(`<(input|select)\b[^>]*>`)
attrRe = regexp.MustCompile(`([a-z-]+)(?:="([^"]*)")?`)
optionRe = regexp.MustCompile(`<option value="([^"]*)"( selected)?>`)
)
// submitted returns what a browser would send when the user form in body is
// saved untouched: every control that is not disabled, a checkbox only when it
// is checked, a select as its selected option, a text field as it is filled.
func submitted(t *testing.T, body string) url.Values {
t.Helper()
start := strings.Index(body, `<form class="sp-form"`)
end := strings.Index(body[start:], "</form>")
if start < 0 || end < 0 {
t.Fatalf("no user form in the page:\n%s", body)
}
form := body[start : start+end]
values := url.Values{}
for _, loc := range tagRe.FindAllStringIndex(form, -1) {
tag := form[loc[0]:loc[1]]
attrs := map[string]string{}
for _, m := range attrRe.FindAllStringSubmatch(strings.TrimSuffix(strings.TrimPrefix(tag, "<"), ">"), -1) {
attrs[m[1]] = html.UnescapeString(m[2])
}
if _, off := attrs["disabled"]; off || attrs["name"] == "" {
continue
}
switch {
case strings.HasPrefix(tag, "<select"):
closeAt := strings.Index(form[loc[1]:], "</select>")
for _, o := range optionRe.FindAllStringSubmatch(form[loc[1]:loc[1]+closeAt], -1) {
if o[2] != "" {
values.Add(attrs["name"], html.UnescapeString(o[1]))
}
}
case attrs["type"] == "checkbox":
if _, on := attrs["checked"]; on {
values.Add(attrs["name"], attrs["value"])
}
default:
values.Add(attrs["name"], attrs["value"])
}
}
return values
}
// Saving the user form untouched must not alter the user: the role, both lists,
// both All flags and the account e-mail come back as they were, the password
// field is blank and so keeps the hash.
func TestUserFormSavedUntouchedChangesNothing(t *testing.T) {
h, st := inboundHandlers(t)
out1, _ := st.AddDomain("one.example.com", "mail")
out2, _ := st.AddDomain("two.example.com", "mail")
in1, _ := st.AddInboundDomain("in.example.com")
in2, _ := st.AddInboundDomain("other.example.com")
root := userWith(t, st, "root", store.RoleGlobal, store.Reach{})
cases := map[string]store.Reach{
"named": {DomainIDs: []int64{out1.ID}, InboundDomainIDs: []int64{in2.ID}},
"all-out": {AllDomains: true, InboundDomainIDs: []int64{in1.ID, in2.ID}},
"all-in": {DomainIDs: []int64{out1.ID, out2.ID}, AllInbound: true},
"all-both": {AllDomains: true, AllInbound: true},
}
for name, reach := range cases {
t.Run(name, func(t *testing.T) {
p := userWith(t, st, "user-"+name, store.RoleDomain, reach)
email := name + "@example.org"
if name == "all-both" {
email = "" // a user who has none must come back with none
}
if err := st.UpdateUser(p.ID, "user-"+name, "a-real-hash", email); err != nil {
t.Fatal(err)
}
before, _ := st.GetUser(p.ID)
path := "/server/users/" + idStr(p.ID)
pv := map[string]string{"uid": idStr(p.ID)}
get := send(h.HandleUserEdit, &root, "GET", path, pv, nil)
if get.Code != http.StatusOK {
t.Fatalf("GET = %d", get.Code)
}
form := submitted(t, get.Body.String())
if form.Get("password") != "" {
t.Fatalf("the password field is not blank: %q", form.Get("password"))
}
if _, sent := form["email"]; !sent || form.Get("email") != email {
t.Fatalf("the form sends e-mail %q (sent: %v), want %q", form.Get("email"), sent, email)
}
post := send(h.HandleUserEdit, &root, "POST", path, pv, form)
if post.Code != http.StatusSeeOther {
t.Fatalf("POST of the untouched form = %d:\n%s", post.Code, post.Body.String())
}
after, _ := st.GetUser(p.ID)
if after.Username != before.Username || after.PasswordHash != before.PasswordHash || after.Role != before.Role ||
after.Email != before.Email || after.DMARCDefaultMode != before.DMARCDefaultMode ||
!equalReach(after.Reach(), before.Reach()) {
t.Errorf("saving untouched changed the user:\nbefore %+v\nafter %+v", before, after)
}
})
}
}
func equalReach(a, b store.Reach) bool {
same := func(x, y []int64) bool {
if len(x) != len(y) {
return false
}
for i := range x {
if x[i] != y[i] {
return false
}
}
return true
}
return a.AllDomains == b.AllDomains && a.AllInbound == b.AllInbound &&
same(a.DomainIDs, b.DomainIDs) && same(a.InboundDomainIDs, b.InboundDomainIDs)
}
// A global user is saved untouched too, and the only global user — whose role
// select is read-only — keeps the role through the hidden field.
func TestUserFormOfTheOnlyGlobalUserSavesUntouched(t *testing.T) {
h, st := inboundHandlers(t)
st.AddDomain("one.example.com", "mail")
root := userWith(t, st, "root", store.RoleGlobal, store.Reach{})
before, _ := st.GetUser(root.ID)
path := "/server/users/" + idStr(root.ID)
pv := map[string]string{"uid": idStr(root.ID)}
get := send(h.HandleUserEdit, &root, "GET", path, pv, nil).Body.String()
for _, want := range []string{`<input type="hidden" name="role" value="global">`, `<select id="role" name="role" disabled>`} {
if !strings.Contains(get, want) {
t.Errorf("the form of the only global user is missing %q", want)
}
}
if strings.Contains(get, "/delete") {
t.Error("the only global user is offered Delete user")
}
form := submitted(t, get)
if form.Get("role") != "global" {
t.Fatalf("the untouched form sends role %q", form.Get("role"))
}
if rec := send(h.HandleUserEdit, &root, "POST", path, pv, form); rec.Code != http.StatusSeeOther {
t.Fatalf("POST = %d:\n%s", rec.Code, rec.Body.String())
}
after, _ := st.GetUser(root.ID)
if after.Role != before.Role || after.PasswordHash != before.PasswordHash || !after.Reach().Empty() {
t.Errorf("saving the only global user untouched changed them: %+v -> %+v", before, after)
}
// Another global user exists: the role is a real choice again, and Delete is offered.
other := userWith(t, st, "second", store.RoleGlobal, store.Reach{})
get = send(h.HandleUserEdit, &root, "GET", "/server/users/"+idStr(other.ID), map[string]string{"uid": idStr(other.ID)}, nil).Body.String()
if strings.Contains(get, "disabled") || !strings.Contains(get, `href="/server/users/`+idStr(other.ID)+`/delete"`) {
t.Errorf("with two global users the form should be free:\n%s", get)
}
}
// A refused save comes back as the flash with the form as it was typed, the head
// naming the stored user.
func TestUserFormRefusalIsTheFlash(t *testing.T) {
h, st := inboundHandlers(t)
root := userWith(t, st, "root", store.RoleGlobal, store.Reach{})
userWith(t, st, "taken", store.RoleGlobal, store.Reach{})
rec := send(h.HandleUserEdit, &root, "POST", "/server/users/"+idStr(root.ID), map[string]string{"uid": idStr(root.ID)},
url.Values{"username": {"taken"}, "role": {"global"}})
body := rec.Body.String()
if rec.Code != http.StatusConflict {
t.Fatalf("a taken name = %d", rec.Code)
}
for _, want := range []string{`<div class="notification is-danger is-light">`, "That username is already in use.",
`<h1 class="title is-3">root</h1>`, `name="username" value="taken"`} {
if !strings.Contains(body, want) {
t.Errorf("the refused form is missing %q", want)
}
}
// The create form comes back the same way, with what was typed.
rec = send(h.HandleUserNew, &root, "POST", "/server/users/new", nil, url.Values{"username": {"fresh"}, "password": {"short"}, "role": {"domain"}})
body = rec.Body.String()
if rec.Code != http.StatusBadRequest || !strings.Contains(body, "password must be at least") ||
!strings.Contains(body, `<h1 class="title is-3">Create user</h1>`) || !strings.Contains(body, `name="username" value="fresh"`) {
t.Errorf("a refused create form = %d:\n%s", rec.Code, body)
}
}
// ---- Delete a user
func TestUserDeletePageNamesTheAssignmentsAndRefusalsStayOnTheForm(t *testing.T) {
h, st := inboundHandlers(t)
out1, _ := st.AddDomain("out.example.com", "mail")
in1, _ := st.AddInboundDomain("in.example.com")
root := userWith(t, st, "root", store.RoleGlobal, store.Reach{})
u := userWith(t, st, "shop", store.RoleDomain, store.Reach{DomainIDs: []int64{out1.ID}, InboundDomainIDs: []int64{in1.ID}})
path := "/server/users/" + idStr(u.ID) + "/delete"
body := send(h.HandleUserDeleteConfirm, &root, "GET", path, map[string]string{"uid": idStr(u.ID)}, nil).Body.String()
for _, want := range []string{
`<h1 class="title is-3">Delete shop</h1>`, `<div class="box sp-danger-zone">`,
`remove the assignments to <strong>out.example.com</strong> (outbound) and to <strong>in.example.com</strong> (inbound).`,
`<form method="post" action="` + path + `">`, `href="/server/users/` + idStr(u.ID) + `">Keep it`,
} {
if !strings.Contains(body, want) {
t.Errorf("the delete page is missing %q", want)
}
}
// Nobody deletes themselves or the last global user: the refusal is the flash on the form.
rec := send(h.HandleUserDelete, &root, "POST", "/server/users/"+idStr(root.ID)+"/delete", map[string]string{"uid": idStr(root.ID)}, url.Values{})
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "You cannot delete your own account while signed in.") ||
!strings.Contains(rec.Body.String(), `class="notification is-danger is-light"`) {
t.Errorf("deleting yourself = %d:\n%s", rec.Code, rec.Body.String())
}
if _, err := st.GetUser(root.ID); err != nil {
t.Errorf("the refused delete removed the user: %v", err)
}
if rec := send(h.HandleUserDelete, &root, "POST", path, map[string]string{"uid": idStr(u.ID)}, url.Values{}); rec.Code != http.StatusSeeOther {
t.Errorf("deleting another user = %d", rec.Code)
}
if _, err := st.GetUser(u.ID); err == nil {
t.Error("the user is still there after the confirmed delete")
}
}
// ---- Help
// Help is for both roles. The topics about the checks of Overview and Health are
// shown to the global role only.
func TestHelpPageForBothRoles(t *testing.T) {
h, st := inboundHandlers(t)
dom, _ := st.AddDomain("out.example.com", "mail")
ops := userWith(t, st, "ops", store.RoleDomain, store.Reach{DomainIDs: []int64{dom.ID}})
root := auth.Principal{ID: 1, Username: "admin", Role: auth.RoleGlobal}
for _, tt := range []struct {
who auth.Principal
wantCheck bool
}{{root, true}, {ops, false}} {
rec := send(h.HandleHelp, &tt.who, "GET", "/help", nil, nil)
if rec.Code != http.StatusOK {
t.Fatalf("GET /help as %s = %d", tt.who.Username, rec.Code)
}
body := rec.Body.String()
for _, want := range []string{`<h1 class="title is-3">Help</h1>`, `id="dns"`, `id="apps"`, `id="limits"`} {
if !strings.Contains(body, want) {
t.Errorf("Help as %s is missing %q", tt.who.Username, want)
}
}
if got := strings.Contains(body, `id="checks"`); got != tt.wantCheck {
t.Errorf("Help as %s: Overview topics present = %v, want %v", tt.who.Username, got, tt.wantCheck)
}
}
}
// ---- Backup
// A refused pre-flight says what to add to docker-compose.yml as plain text: the
// message is escaped on the page, so a tag in it would be printed as one.
func TestBackupDeployRefusalIsPlainText(t *testing.T) {
msg := deployBackupErr(errString("DeployRoot is not mounted"))
if strings.ContainsAny(msg, "<>") || !strings.Contains(msg, ".:/selfpost-deploy:ro") {
t.Errorf("deployBackupErr = %q", msg)
}
}
type errString string
func (e errString) Error() string { return string(e) }
+149 -57
View File
@@ -8,20 +8,25 @@ import (
"github.com/mixeme/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/web/validate"
"github.com/mixeme/selfpost/internal/web/view"
"golang.org/x/crypto/bcrypt"
)
// userFormView is what the user form is drawn with: a refusal, and the values
// the form holds — the stored user's, or what was typed into a form that was
// refused.
type userFormView struct {
FormErr string
FormUsername string
FormEmail string
FormRole string
Reach store.Reach
FormPassword string
}
// HandleUsers lists panel users (global only).
func (h *Handlers) HandleUsers(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requireGlobal(w, r); !ok {
p, ok := h.requireGlobal(w, r)
if !ok {
return
}
rows, err := h.store.ListUserRows()
@@ -30,12 +35,17 @@ func (h *Handlers) HandleUsers(w http.ResponseWriter, r *http.Request) {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
data := h.pageBase(r)
data["Title"] = "SelfPost — users"
data["Active"] = "users"
data["Users"] = rows
data["Flash"] = usersFlash(r)
h.view.Render(w, http.StatusOK, "users", data)
list := make([]view.UserRow, len(rows))
for i, row := range rows {
u := row.User
list[i] = view.NewUserRow(view.UserRowInput{
ID: u.ID, Username: u.Username, Email: u.Email, Global: u.Role == store.RoleGlobal, You: u.ID == p.ID,
AllOutbound: u.AllDomains, AllInbound: u.AllInboundDomains,
Outbound: row.DomainNames, Inbound: row.InboundDomainNames,
})
}
page := view.NewUsers(h.shellMeta(r), h.cfg.InboundEnabled, usersFlash(r)).WithRows(list)
h.view.Render(w, http.StatusOK, "users", page)
}
func usersFlash(r *http.Request) string {
@@ -90,12 +100,13 @@ func (h *Handlers) HandleUserEdit(w http.ResponseWriter, r *http.Request) {
case http.MethodGet:
h.renderUserForm(w, r, http.StatusOK, u.ID, userFormView{
FormUsername: u.Username,
FormEmail: u.Email,
FormRole: string(u.Role),
Reach: u.Reach(),
})
case http.MethodPost:
if err := r.ParseForm(); err != nil {
h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: "Invalid form submission.", FormUsername: u.Username, FormRole: string(u.Role)})
h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: "Invalid form submission.", FormUsername: u.Username, FormEmail: u.Email, FormRole: string(u.Role)})
return
}
h.submitUserUpdate(w, r, u)
@@ -105,32 +116,36 @@ func (h *Handlers) HandleUserEdit(w http.ResponseWriter, r *http.Request) {
}
}
func (h *Handlers) renderUserForm(w http.ResponseWriter, r *http.Request, status int, userID int64, view userFormView) {
func (h *Handlers) renderUserForm(w http.ResponseWriter, r *http.Request, status int, userID int64, form userFormView) {
domains, err := h.store.ListDomains()
if err != nil {
logf("panel: user form: list domains: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
data := h.pageBase(r)
in := view.UserFormInput{
ID: userID, Username: form.FormUsername, Email: form.FormEmail, Role: form.FormRole,
RoleLocked: lastGlobalLocked(h, userID, form.FormRole),
PasswordMin: validate.MinAdminPasswordLen,
ShowInbound: h.cfg.InboundEnabled,
AllOut: form.Reach.AllDomains, AllIn: form.Reach.AllInbound,
Error: form.FormErr,
}
// The head names the user as stored, not as typed into a form that was
// refused; Delete user is not offered for the only global user.
if userID != 0 {
data["Title"] = "SelfPost — edit user"
} else {
data["Title"] = "SelfPost — create user"
in.Name = form.FormUsername
if u, err := h.store.GetUser(userID); err == nil {
in.Name = u.Username
}
in.CanDelete = !in.RoleLocked
}
out := idSet(form.Reach.DomainIDs)
for _, d := range domains {
in.OutDomains = append(in.OutDomains, view.DomainChoice{ID: d.ID, Name: d.Name, Checked: out[d.ID]})
}
data["Active"] = "users"
data["UserID"] = userID
data["Domains"] = domains
data["Error"] = view.FormErr
data["FormUsername"] = view.FormUsername
data["FormRole"] = view.FormRole
data["GlobalRole"] = store.RoleGlobal
// The two lists of the form, each with its All tick. Inbound is offered
// only where the feature is on.
data["FormDomains"] = idSet(view.Reach.DomainIDs)
data["FormAllDomains"] = view.Reach.AllDomains
data["FormInbound"] = idSet(view.Reach.InboundDomainIDs)
data["FormAllInbound"] = view.Reach.AllInbound
if h.cfg.InboundEnabled {
inboundDomains, err := h.store.ListInboundDomains()
if err != nil {
@@ -138,14 +153,16 @@ func (h *Handlers) renderUserForm(w http.ResponseWriter, r *http.Request, status
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
data["InboundDomains"] = inboundDomains
inb := idSet(form.Reach.InboundDomainIDs)
for _, d := range inboundDomains {
in.InDomains = append(in.InDomains, view.DomainChoice{ID: d.ID, Name: d.Name, Checked: inb[d.ID]})
}
}
data["FormPassword"] = view.FormPassword
data["IsEdit"] = userID != 0
data["LastGlobalLocked"] = lastGlobalLocked(h, userID, view.FormRole)
h.view.Render(w, status, "user_form", data)
h.view.Render(w, status, "user", view.NewUserForm(h.shellMeta(r), in))
}
// lastGlobalLocked is whether the form's user is the only global one, whose role
// cannot be changed (the form shows it read-only).
func lastGlobalLocked(h *Handlers, userID int64, formRole string) bool {
if userID == 0 || formRole != string(store.RoleGlobal) {
return false
@@ -160,24 +177,29 @@ func (h *Handlers) submitUserCreate(w http.ResponseWriter, r *http.Request) {
return
}
username := strings.TrimSpace(r.PostFormValue("username"))
email := strings.TrimSpace(r.PostFormValue("email"))
password := r.PostFormValue("password")
role := store.Role(r.PostFormValue("role"))
reach := h.reachFromForm(r, store.Reach{})
if err := validate.Username(username); err != nil {
h.renderUserForm(w, r, http.StatusBadRequest, 0, userFormView{FormErr: err.Error(), FormUsername: username, FormRole: string(role), Reach: reach})
h.renderUserForm(w, r, http.StatusBadRequest, 0, userFormView{FormErr: err.Error(), FormUsername: username, FormEmail: email, FormRole: string(role), Reach: reach})
return
}
if err := validate.Email(email); err != nil {
h.renderUserForm(w, r, http.StatusBadRequest, 0, userFormView{FormErr: err.Error(), FormUsername: username, FormEmail: email, FormRole: string(role), Reach: reach})
return
}
if err := validate.AdminPassword(password); err != nil {
h.renderUserForm(w, r, http.StatusBadRequest, 0, userFormView{FormErr: err.Error(), FormUsername: username, FormRole: string(role), Reach: reach})
h.renderUserForm(w, r, http.StatusBadRequest, 0, userFormView{FormErr: err.Error(), FormUsername: username, FormEmail: email, FormRole: string(role), Reach: reach})
return
}
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), Reach: reach})
h.renderUserForm(w, r, http.StatusBadRequest, 0, userFormView{FormErr: "Choose a valid role.", FormUsername: username, FormEmail: email, FormRole: string(role), Reach: reach})
return
}
if role == store.RoleDomain && reach.Empty() {
h.renderUserForm(w, r, http.StatusBadRequest, 0, userFormView{FormErr: "Assign at least one outbound or inbound domain to a domain administrator.", FormUsername: username, FormRole: string(role), Reach: reach})
h.renderUserForm(w, r, http.StatusBadRequest, 0, userFormView{FormErr: "Assign at least one outbound or inbound domain to a domain administrator.", FormUsername: username, FormEmail: email, FormRole: string(role), Reach: reach})
return
}
@@ -187,21 +209,31 @@ func (h *Handlers) submitUserCreate(w http.ResponseWriter, r *http.Request) {
h.renderUserForm(w, r, http.StatusInternalServerError, 0, userFormView{FormErr: "Internal error. Please try again."})
return
}
if _, err := h.store.CreateUser(username, string(hash), role, reach); err != nil {
id, err := h.store.CreateUser(username, string(hash), role, reach)
if err != nil {
if errors.Is(err, store.ErrUserExists) {
h.renderUserForm(w, r, http.StatusConflict, 0, userFormView{FormErr: "That username is already in use.", FormUsername: username, FormRole: string(role), Reach: reach})
h.renderUserForm(w, r, http.StatusConflict, 0, userFormView{FormErr: "That username is already in use.", FormUsername: username, FormEmail: email, FormRole: string(role), Reach: reach})
return
}
logf("panel: create user: %v", err)
h.renderUserForm(w, r, http.StatusInternalServerError, 0, userFormView{FormErr: "Could not create user. Please check the logs."})
return
}
// No domain follows a user who has just been created, so there is nothing
// to resync when their address is first stored.
if email != "" {
if err := h.store.UpdateUser(id, username, string(hash), email); err != nil {
logf("panel: create user %d: store e-mail: %v", id, err)
h.renderUserForm(w, r, http.StatusInternalServerError, id, userFormView{FormErr: "The user was created, but the e-mail could not be saved. Set it here.", FormUsername: username, FormEmail: email, FormRole: string(role), Reach: reach})
return
}
}
http.Redirect(w, r, "/server/users?done=created", http.StatusSeeOther)
}
func (h *Handlers) submitUserUpdate(w http.ResponseWriter, r *http.Request, u store.User) {
if err := r.ParseForm(); err != nil {
h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: "Invalid form submission.", FormUsername: u.Username, FormRole: string(u.Role)})
h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: "Invalid form submission.", FormUsername: u.Username, FormEmail: u.Email, FormRole: string(u.Role)})
return
}
p, ok := h.principal(r)
@@ -211,6 +243,12 @@ func (h *Handlers) submitUserUpdate(w http.ResponseWriter, r *http.Request, u st
}
username := strings.TrimSpace(r.PostFormValue("username"))
// The form always sends the e-mail, empty when cleared. A post without the
// field at all is not a request to clear it.
email := u.Email
if _, sent := r.PostForm["email"]; sent {
email = strings.TrimSpace(r.PostFormValue("email"))
}
password := r.PostFormValue("password")
role := store.Role(r.PostFormValue("role"))
reach := h.reachFromForm(r, u.Reach())
@@ -219,15 +257,19 @@ func (h *Handlers) submitUserUpdate(w http.ResponseWriter, r *http.Request, u st
username = u.Username
}
if err := validate.Username(username); err != nil {
h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: err.Error(), FormUsername: username, FormRole: string(role), Reach: reach})
h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: err.Error(), FormUsername: username, FormEmail: email, FormRole: string(role), Reach: reach})
return
}
if err := validate.Email(email); err != nil {
h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: err.Error(), FormUsername: username, FormEmail: email, FormRole: string(role), Reach: reach})
return
}
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), Reach: reach})
h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: "Choose a valid role.", FormUsername: username, FormEmail: email, FormRole: string(role), Reach: reach})
return
}
if role == store.RoleDomain && reach.Empty() {
h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: "Assign at least one outbound or inbound domain to a domain administrator.", FormUsername: username, FormRole: string(role), Reach: reach})
h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: "Assign at least one outbound or inbound domain to a domain administrator.", FormUsername: username, FormEmail: email, FormRole: string(role), Reach: reach})
return
}
@@ -238,7 +280,7 @@ func (h *Handlers) submitUserUpdate(w http.ResponseWriter, r *http.Request, u st
if u.ID == p.ID {
msg = "You cannot demote yourself without another global administrator."
}
h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: msg, FormUsername: username, FormRole: string(u.Role), Reach: reach})
h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: msg, FormUsername: username, FormEmail: email, FormRole: string(u.Role), Reach: reach})
return
}
}
@@ -246,32 +288,33 @@ func (h *Handlers) submitUserUpdate(w http.ResponseWriter, r *http.Request, u st
hash := u.PasswordHash
if password != "" {
if err := validate.AdminPassword(password); err != nil {
h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: err.Error(), FormUsername: username, FormRole: string(role), Reach: reach})
h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: err.Error(), FormUsername: username, FormEmail: email, FormRole: string(role), Reach: reach})
return
}
newHash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost)
if err != nil {
logf("panel: update user hash: %v", err)
h.renderUserForm(w, r, http.StatusInternalServerError, u.ID, userFormView{FormErr: "Internal error. Please try again.", FormUsername: username, FormRole: string(role), Reach: reach})
h.renderUserForm(w, r, http.StatusInternalServerError, u.ID, userFormView{FormErr: "Internal error. Please try again.", FormUsername: username, FormEmail: email, FormRole: string(role), Reach: reach})
return
}
hash = string(newHash)
}
if err := h.store.UpdateUser(u.ID, username, hash, u.Email); err != nil {
if err := h.store.UpdateUser(u.ID, username, hash, 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), Reach: reach})
h.renderUserForm(w, r, http.StatusConflict, u.ID, userFormView{FormErr: "That username is already in use.", FormUsername: username, FormEmail: email, FormRole: string(role), Reach: reach})
return
}
logf("panel: update user: %v", err)
h.renderUserForm(w, r, http.StatusInternalServerError, u.ID, userFormView{FormErr: "Could not save user. Please check the logs.", FormUsername: username, FormRole: string(role), Reach: reach})
h.renderUserForm(w, r, http.StatusInternalServerError, u.ID, userFormView{FormErr: "Could not save user. Please check the logs.", FormUsername: username, FormEmail: email, FormRole: string(role), Reach: reach})
return
}
h.resyncAfterEmailChange(u, email)
if role != u.Role {
if err := h.store.SetUserRole(u.ID, role); err != nil {
logf("panel: set user role: %v", err)
h.renderUserForm(w, r, http.StatusInternalServerError, u.ID, userFormView{FormErr: "Could not update role.", FormUsername: username, FormRole: string(role), Reach: reach})
h.renderUserForm(w, r, http.StatusInternalServerError, u.ID, userFormView{FormErr: "Could not update role.", FormUsername: username, FormEmail: email, FormRole: string(role), Reach: reach})
return
}
if role == store.RoleGlobal {
@@ -284,7 +327,7 @@ func (h *Handlers) submitUserUpdate(w http.ResponseWriter, r *http.Request, u st
if role == store.RoleDomain {
if err := h.store.SetUserReach(u.ID, reach); err != nil {
logf("panel: set user reach: %v", err)
h.renderUserForm(w, r, http.StatusInternalServerError, u.ID, userFormView{FormErr: "Could not save domain assignments.", FormUsername: username, FormRole: string(role), Reach: reach})
h.renderUserForm(w, r, http.StatusInternalServerError, u.ID, userFormView{FormErr: "Could not save domain assignments.", FormUsername: username, FormEmail: email, FormRole: string(role), Reach: reach})
return
}
}
@@ -313,12 +356,61 @@ func (h *Handlers) HandleUserDeleteConfirm(w http.ResponseWriter, r *http.Reques
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
data := h.pageBase(r)
data["Title"] = "SelfPost — delete " + u.Username
data["Active"] = "users"
data["TargetID"] = u.ID
data["TargetUsername"] = u.Username
h.view.Render(w, http.StatusOK, "user_delete", data)
in := view.UserDeleteInput{
ID: u.ID, Username: u.Username, Global: u.Role == store.RoleGlobal,
AllOutbound: u.AllDomains, AllInbound: u.AllInboundDomains,
}
if !in.Global {
if in.Outbound, in.Inbound, err = h.assignedNames(u); err != nil {
logf("panel: delete user %d: assigned domains: %v", u.ID, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
}
// Domains that follow this user's default report address are left with none.
domains, err := h.store.ListDomains()
if err != nil {
logf("panel: delete user %d: list domains: %v", u.ID, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
for _, d := range domains {
if d.DMARCRuaUserID.Valid && d.DMARCRuaUserID.Int64 == u.ID {
in.Following = append(in.Following, d.Name)
}
}
h.view.Render(w, http.StatusOK, "user-delete", view.NewUserDelete(h.shellMeta(r), in))
}
// assignedNames names the domains a domain user is assigned one by one, outbound
// and inbound: what deleting the user takes away. A direction the user holds as
// All has no names.
func (h *Handlers) assignedNames(u store.User) (outbound, inbound []string, err error) {
if len(u.DomainIDs) > 0 && !u.AllDomains {
domains, err := h.store.ListDomains()
if err != nil {
return nil, nil, err
}
assigned := idSet(u.DomainIDs)
for _, d := range domains {
if assigned[d.ID] {
outbound = append(outbound, d.Name)
}
}
}
if len(u.InboundDomainIDs) > 0 && !u.AllInboundDomains {
domains, err := h.store.ListInboundDomains()
if err != nil {
return nil, nil, err
}
assigned := idSet(u.InboundDomainIDs)
for _, d := range domains {
if assigned[d.ID] {
inbound = append(inbound, d.Name)
}
}
}
return outbound, inbound, nil
}
// HandleUserDelete performs the deletion confirmed on HandleUserDeleteConfirm
@@ -351,16 +443,16 @@ func (h *Handlers) submitUserDelete(w http.ResponseWriter, r *http.Request, u st
return
}
if u.ID == p.ID {
h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: "You cannot delete your own account while signed in.", FormUsername: u.Username, FormRole: string(u.Role)})
h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: "You cannot delete your own account while signed in.", FormUsername: u.Username, FormEmail: u.Email, FormRole: string(u.Role)})
return
}
if err := h.store.DeleteUser(u.ID); err != nil {
if errors.Is(err, store.ErrLastGlobal) {
h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: "Cannot delete the last global administrator.", FormUsername: u.Username, FormRole: string(u.Role)})
h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: "Cannot delete the last global administrator.", FormUsername: u.Username, FormEmail: u.Email, FormRole: string(u.Role)})
return
}
logf("panel: delete user %d: %v", u.ID, err)
h.renderUserForm(w, r, http.StatusInternalServerError, u.ID, userFormView{FormErr: "Could not delete user.", FormUsername: u.Username, FormRole: string(u.Role)})
h.renderUserForm(w, r, http.StatusInternalServerError, u.ID, userFormView{FormErr: "Could not delete user.", FormUsername: u.Username, FormEmail: u.Email, FormRole: string(u.Role)})
return
}
http.Redirect(w, r, "/server/users?done=deleted", http.StatusSeeOther)