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:
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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()))
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user