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:
@@ -107,6 +107,12 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
|
||||
whether inbound mail is filtered and what happens while the filter is
|
||||
down. The filter's address is shown to the global role only. `inbound`,
|
||||
`inbound_domain` and `inbound_delete` are off the ratchet.
|
||||
- panel-redesign (stage 2): the **Server** pages - System log, Backup,
|
||||
Users, the user form and its delete confirmation - and **Help** are in the
|
||||
new design. Help is a page of topics that the `?` in a box's head opens;
|
||||
the help drawer is gone. The user form sets the user's e-mail. The backup
|
||||
download is encrypted unless the box is unticked. Only the queue page is
|
||||
left on the ratchet.
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -142,6 +148,9 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
|
||||
application forms (the script applied only the first matching rule).
|
||||
- The DMARC page of a domain no longer answers 500 once the domain has a
|
||||
reported source.
|
||||
- The confirmation before deleting a user names the domains that follow
|
||||
that user's default report address and will be left without one; it used
|
||||
to say the domains were not affected.
|
||||
|
||||
### Removed
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 89 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 180 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 103 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 58 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 99 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 67 KiB |
@@ -518,7 +518,7 @@ only when both are done. Every stage-2 step ends with the evidence of
|
||||
- [x] Outbound: domains, domain, domain settings, application form, shown-once password, delete — **Sonnet**
|
||||
- [x] Outbound: log, message, DMARC hub / domain / report — **Sonnet**
|
||||
- [x] Inbound: domains, domain with the *Spam filter* box, delete — **Sonnet**
|
||||
- [ ] Server: system log, backup, users, user form, delete; Help as `help_topic` partials, the drawer removed — **Sonnet**
|
||||
- [x] Server: system log, backup, users, user form, delete; Help as `help_topic` partials, the drawer removed — **Sonnet**
|
||||
- [ ] Queues: old content inside the new shell (outbound), Inbound › Queue hidden — **Sonnet**
|
||||
- [ ] Remove the old `panel.css`, the drawer code in `panel.js` and every class nothing references; `legacyPages` empty, vocabulary test green with no allow-list — **Sonnet**
|
||||
|
||||
|
||||
+1
-1
@@ -39,7 +39,7 @@ in `git log` and [CHANGELOG.md](../CHANGELOG.md).
|
||||
| panel-notifications | E-mail notifications about important events | candidate | — | — |
|
||||
| delivery-log-storage | Keep a message's `mail.log` lines with its send-log row | candidate | — | — |
|
||||
| password-reset | Password reset by e-mail | candidate | — | — |
|
||||
| panel-redesign | Panel redesign (accepted mockups, design contract) | agreed | 20/27 | [plans/panel-redesign.md](plans/panel-redesign.md) |
|
||||
| panel-redesign | Panel redesign (accepted mockups, design contract) | agreed | 21/27 | [plans/panel-redesign.md](plans/panel-redesign.md) |
|
||||
| schema-squash | Squash SQLite migrations into a 2.x baseline | built in panel-redesign (stage 1) | — | [plans/panel-redesign.md](plans/panel-redesign.md) |
|
||||
|
||||
**Recommended order** (not binding; owner, 2026-10-09: feature order follows
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -62,7 +62,7 @@ func NewAccount(m Meta, role string, hosted bool, accountEmail string) *Account
|
||||
Profile: Box{No: "01", Title: "Profile"},
|
||||
Password: Box{No: "02", Title: "Password"},
|
||||
DMARC: Box{No: "03", Title: "DMARC reports", ID: "dmarc",
|
||||
Help: &HelpLink{Href: "/help#dmarc", Title: "What the reports are and where they go"}},
|
||||
Help: &HelpLink{Href: "/help#" + HelpDNS, Title: "DMARC and where its reports go"}},
|
||||
hosted: hosted,
|
||||
}
|
||||
a.WithDomainUse(0, 0)
|
||||
|
||||
@@ -578,7 +578,7 @@ func TestKitPagesAreTheOnesOffTheRatchet(t *testing.T) {
|
||||
func TestLegacyPagesStayOnTheOldLayout(t *testing.T) {
|
||||
e := kitEngine(t)
|
||||
rec := httptest.NewRecorder()
|
||||
e.Render(rec, http.StatusOK, "backup", map[string]any{"Title": "t", "User": "admin", "IsGlobal": true})
|
||||
e.Render(rec, http.StatusOK, "mail_queue", map[string]any{"Title": "t", "User": "admin", "IsGlobal": true})
|
||||
out := rec.Body.String()
|
||||
mustContain(t, out, `href="/static/legacy.css"`, `class="shell"`)
|
||||
mustNotContain(t, out, "bulma.min.css", "tabler-icons.css", "/static/panel.css", "navbar")
|
||||
|
||||
@@ -44,6 +44,13 @@ var pageFixtures = map[string]func() any{
|
||||
"dmarc": func() any { return dmarcHubFixture() },
|
||||
"dmarc-domain": func() any { return dmarcDomainFixture() },
|
||||
"dmarc-report": func() any { return dmarcReportFixture() },
|
||||
|
||||
"system-log": func() any { return systemLogFixture() },
|
||||
"backup": func() any { return backupFixture() },
|
||||
"users": func() any { return usersFixture() },
|
||||
"user": func() any { return userFixture() },
|
||||
"user-delete": func() any { return userDeleteFixture() },
|
||||
"help": func() any { return NewHelp(admin(), true) },
|
||||
}
|
||||
|
||||
// admin is the signed-in global administrator every fixture is rendered for.
|
||||
@@ -342,3 +349,55 @@ func inDomainFixture() *InDomain {
|
||||
p.WithFilter(true, "inet:antispam:11332", "accept")
|
||||
return p
|
||||
}
|
||||
|
||||
// systemLogFixture is the mockup's System log: ten lines of mail.log, as the
|
||||
// handler reads them (oldest first), with the deferrals in amber and the
|
||||
// refusals and the bounce in red.
|
||||
func systemLogFixture() *SystemLog {
|
||||
return NewSystemLog(admin(), []LogLine{
|
||||
{Time: "Sep 21 13:00:00", Text: "opendkim[39]: key table reloaded (3 domains)"},
|
||||
{Time: "Sep 21 13:40:02", Text: "journal-milter[40]: rate limit: app=prod-server 201/200 in 3600s — tempfail at MAIL FROM", Level: "warn"},
|
||||
{Time: "Sep 21 13:41:19", Text: "postfix/smtp[2098]: 4XcB1c3Rk8z1: to=<j.doe@nonexistent.example>, status=bounced (Host or domain name not found)", Level: "error"},
|
||||
{Time: "Sep 21 13:44:02", Text: "postfix/smtp[2101]: 4XcB2m7Hd4z1: to=<jobs@acme.io>, relay=none, status=deferred (connect to 10.0.4.12[10.0.4.12]:2525: Connection timed out)", Level: "warn"},
|
||||
{Time: "Sep 21 13:57:41", Text: "postfix/smtpd[2119]: NOQUEUE: reject: RCPT from bulk.example[198.51.100.90]: 550 5.1.1 <sales@lists.example.org>: Recipient address rejected: unknown recipient", Level: "error"},
|
||||
{Time: "Sep 21 13:58:09", Text: "postfix/smtp[2123]: 4XcB7k2Jm9z1: to=<noc@example.org>, relay=mx1.example.org[192.0.2.10]:25, status=deferred (451 4.7.1 Greylisted, try again in 5 minutes)", Level: "warn"},
|
||||
{Time: "Sep 21 14:00:18", Text: "postfix/smtp[2130]: 4XcB8z5Tn1z1: to=<dev@lists.example.org>, relay=mx.internal[10.0.4.2]:25, status=sent (250 2.0.0 Ok: queued)"},
|
||||
{Time: "Sep 21 14:00:18", Text: "postfix/smtpd[2129]: connect from mail.uni.example[192.0.2.77]"},
|
||||
{Time: "Sep 21 14:01:52", Text: "postfix/qmgr[58]: 4XcB9q0Lw2z1: removed"},
|
||||
{Time: "Sep 21 14:01:52", Text: "postfix/smtp[2131]: 4XcB9q0Lw2z1: to=<m.keller@gmx.de>, relay=mx00.gmx.net[212.227.15.9]:25, delay=0.8, status=sent (250 Requested mail action okay)"},
|
||||
}, "")
|
||||
}
|
||||
|
||||
// backupFixture is the mockup's Backup: nothing refused, the password minimum
|
||||
// of the encryption fields at twelve.
|
||||
func backupFixture() *Backup {
|
||||
return NewBackup(admin(), 12, "")
|
||||
}
|
||||
|
||||
// usersFixture is the mockup's Users: the signed-in global administrator and
|
||||
// two domain administrators, one of them just created.
|
||||
func usersFixture() *Users {
|
||||
return NewUsers(admin(), true, "User created.").WithRows([]UserRow{
|
||||
NewUserRow(UserRowInput{ID: 1, Username: "admin", Email: "mix@example.org", Global: true, You: true}),
|
||||
NewUserRow(UserRowInput{ID: 2, Username: "shop-team", Email: "team@shop.example.org", Outbound: []string{"shop.example.org"}}),
|
||||
NewUserRow(UserRowInput{ID: 3, Username: "acme-ops", Email: "ops@acme.io", AllOutbound: true, Inbound: []string{"acme.io"}}),
|
||||
})
|
||||
}
|
||||
|
||||
// userFixture is the mockup's user form, filled with acme-ops: a domain
|
||||
// administrator on all outbound domains and on acme.io inbound.
|
||||
func userFixture() *UserForm {
|
||||
return NewUserForm(admin(), UserFormInput{
|
||||
ID: 3, Name: "acme-ops", Username: "acme-ops", Email: "ops@acme.io", Role: "domain", CanDelete: true,
|
||||
PasswordMin: 12, ShowInbound: true,
|
||||
AllOut: true,
|
||||
OutDomains: []DomainChoice{{ID: 1, Name: "example.org"}, {ID: 2, Name: "shop.example.org"}, {ID: 3, Name: "notify.acme.io"}},
|
||||
InDomains: []DomainChoice{{ID: 1, Name: "lists.example.org"}, {ID: 2, Name: "acme.io", Checked: true}},
|
||||
})
|
||||
}
|
||||
|
||||
// userDeleteFixture is the mockup's confirmation: acme-ops, who has all
|
||||
// outbound domains and acme.io inbound.
|
||||
func userDeleteFixture() *UserDelete {
|
||||
return NewUserDelete(admin(), UserDeleteInput{ID: 3, Username: "acme-ops", AllOutbound: true, Inbound: []string{"acme.io"}})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
package view
|
||||
|
||||
// The data of the Help page (templates/help.html, mockup help.html): what each
|
||||
// screen checks and what to do when it is not green, as topics that stack inside
|
||||
// two boxes. The head of a numbered box on another page links to its topic with
|
||||
// the anchor of that topic's ID (Box.Help); nothing is shown in a drawer.
|
||||
//
|
||||
// The topics are plain data written here once. A topic is only worded for
|
||||
// something this build has: Help explains the checks and records the panel
|
||||
// shows, not the features of a later release.
|
||||
|
||||
// Help is the data of the Help page. The Overview box explains the checks of
|
||||
// Overview and Health, which only the global role sees, so it is absent for a
|
||||
// domain administrator along with its entries in the menu.
|
||||
type Help struct {
|
||||
Meta
|
||||
Head Head
|
||||
|
||||
Menu SideMenu
|
||||
|
||||
Overview Box
|
||||
OverviewTopics []HelpTopic
|
||||
// ShowOverview is whether the Overview box is present.
|
||||
ShowOverview bool
|
||||
|
||||
Outbound Box
|
||||
OutboundTopics []HelpTopic
|
||||
}
|
||||
|
||||
// Anchors of the Help page. A link to one of these is a Box.Help of another
|
||||
// page; the page must carry exactly the topics its links point at.
|
||||
const (
|
||||
HelpChecks = "checks"
|
||||
HelpCertificate = "certificate"
|
||||
HelpRDNS = "rdns"
|
||||
HelpDNS = "dns"
|
||||
HelpApps = "apps"
|
||||
HelpLimits = "limits"
|
||||
)
|
||||
|
||||
// NewHelp builds the page for a viewer. global is the role that sees Overview
|
||||
// and Server, and so the topics about their checks.
|
||||
func NewHelp(m Meta, global bool) *Help {
|
||||
m.Title, m.Section, m.Page = "Help", "user", "help"
|
||||
p := &Help{
|
||||
Meta: m,
|
||||
Head: Head{
|
||||
Kicker: "Reference",
|
||||
Title: "Help",
|
||||
Lead: Plain("What each screen checks and what to do when it is not green. The ? in a box's head opens its topic here."),
|
||||
},
|
||||
ShowOverview: global,
|
||||
Overview: Box{No: "01", Title: "Overview"},
|
||||
OverviewTopics: []HelpTopic{
|
||||
{ID: HelpChecks, Title: "Health checks", Body: []Text{
|
||||
Rich("Six checks, and the worst of them is the postmark. ", TagOf("ok", ""), " needs nothing; ",
|
||||
TagOf("warn", ""), " still delivers mail but will stop doing so if ignored; ",
|
||||
TagOf("fail", ""), " means mail is being lost or refused now. ", Link("/server/health", "Health"),
|
||||
" shows the readings behind them: CPU and memory are the container's own, not the host's spare capacity, and network is a short window, not a daily total."),
|
||||
}},
|
||||
{ID: HelpCertificate, Title: "TLS certificate", Body: []Text{
|
||||
Plain("The certificate Postfix presents on 465 and 587. The reverse proxy or the image mounts it, the panel does not issue it. It warns 14 days before expiry and fails once expired, or when the file is missing or unreadable — clients that verify certificates then refuse to connect."),
|
||||
}},
|
||||
{ID: HelpRDNS, Title: "Hostname and reverse DNS", Body: []Text{
|
||||
Rich("The hostname (", Code("SELFPOST_HOSTNAME"), ") must resolve to this server's address, and that address must resolve back to the hostname. Large receivers reject or spam-folder mail from hosts where the two disagree. The PTR record is set at your hosting provider, not in your DNS zone."),
|
||||
}},
|
||||
},
|
||||
Outbound: Box{No: "02", Title: "Outbound"},
|
||||
OutboundTopics: []HelpTopic{
|
||||
{ID: HelpDNS, Title: "DNS records", Body: []Text{
|
||||
Rich("DKIM proves the message was signed by this relay, SPF lists the addresses allowed to send for the domain, DMARC tells receivers what to do when both fail and where to report. Publish all three exactly as shown; merge SPF into an existing record rather than adding a second one. A domain's status is the worst of its three records, and results are cached for a few minutes, so use Re-check DNS after publishing. The SPF check is shallow: it looks for this server's address in the record itself and does not follow ",
|
||||
Code("include:"), " or ", Code("redirect="), ", so a record that relies on them is reported as a warning, not a failure. ",
|
||||
Code("p=none"), " does not affect delivery; tighten to ", Code("quarantine"), ", then ", Code("reject"),
|
||||
", once the reports look clean. A domain's report address is set in its settings; the default it follows belongs to a user and is set under ",
|
||||
Link("/account#dmarc", "Account"), "."),
|
||||
}},
|
||||
{ID: HelpApps, Title: "Applications", Body: []Text{
|
||||
Plain("An application is one SASL login, unique across all domains. It may send as any address of its domain or only as listed addresses, and, with a list of client IPs set, only from those addresses. The password is shown once at creation or regeneration and is not stored in readable form."),
|
||||
}},
|
||||
{ID: HelpLimits, Title: "Rate limits", Body: []Text{
|
||||
Rich("Level 1 is a per-IP ceiling set in ", Code(".env"), ". Level 2 is optional, per domain and per application, entered by hand or computed from the last 30 days of sending, and can never exceed level 1. An application's own limit replaces its domain's."),
|
||||
}},
|
||||
},
|
||||
}
|
||||
p.Menu = SideMenu{}
|
||||
if global {
|
||||
p.Menu.Groups = append(p.Menu.Groups, MenuGroup{Label: "Overview", Items: []MenuItem{
|
||||
{Label: "Health checks", Icon: "ti-activity-heartbeat", Href: "#" + HelpChecks},
|
||||
{Label: "TLS certificate", Icon: "ti-certificate", Href: "#" + HelpCertificate},
|
||||
{Label: "Reverse DNS", Icon: "ti-arrows-exchange", Href: "#" + HelpRDNS},
|
||||
}})
|
||||
}
|
||||
p.Menu.Groups = append(p.Menu.Groups, MenuGroup{Label: "Outbound", Items: []MenuItem{
|
||||
{Label: "DNS records", Icon: "ti-world-check", Href: "#" + HelpDNS},
|
||||
{Label: "Applications", Icon: "ti-apps", Href: "#" + HelpApps},
|
||||
{Label: "Rate limits", Icon: "ti-gauge", Href: "#" + HelpLimits},
|
||||
}})
|
||||
return p
|
||||
}
|
||||
@@ -295,8 +295,7 @@ func NewInDomain(m Meta, in InDomainInput) *InDomain {
|
||||
},
|
||||
id: in.ID, name: in.Name, dnsStatus: in.DNSStatus, recipientCount: in.RecipientCount, recipientMode: in.RecipientMode,
|
||||
|
||||
DNS: Box{No: "01", Title: "MX record", ID: "dns",
|
||||
Help: &HelpLink{Href: "/help#inbound", Title: "Backup-MX and forwarding"}},
|
||||
DNS: Box{No: "01", Title: "MX record", ID: "dns"},
|
||||
|
||||
Upstream: Box{No: "02", Title: "Upstream", ID: "upstream", End: Plain("Not a mailbox")},
|
||||
UpstreamAction: href + "/upstream",
|
||||
|
||||
@@ -10,10 +10,4 @@
|
||||
# components.html, components.go, layout.html, GET /server/components
|
||||
# @routes the pre-2.0 route table is still in place (stage 1)
|
||||
# <name> a page of the view engine that is still the old template
|
||||
backup
|
||||
help
|
||||
mail_queue
|
||||
system_log
|
||||
user_delete
|
||||
user_form
|
||||
users
|
||||
|
||||
@@ -0,0 +1,458 @@
|
||||
package view
|
||||
|
||||
// The data of the Server pages that are not a health table: the system log
|
||||
// (templates/system_log.html, mockup system-log.html), the backup and the
|
||||
// domain import (backup.html, backup.html), the users list (users.html,
|
||||
// users.html), the user form (user.html, user.html) and the confirmation before
|
||||
// a user is deleted (user_delete.html, user-delete.html). Everything under
|
||||
// /server/ is for the global role.
|
||||
//
|
||||
// A page is a typed struct that embeds Meta, built by a constructor and filled
|
||||
// by With… methods; the handler reads the stores, the view decides what is said
|
||||
// and how it is worded.
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
serverLog = "/server/log"
|
||||
serverBackup = "/server/backup"
|
||||
serverUsers = "/server/users"
|
||||
)
|
||||
|
||||
// UserHref is the address of a panel user's form.
|
||||
func UserHref(id int64) string { return serverUsers + "/" + strconv.FormatInt(id, 10) }
|
||||
|
||||
// ---- System log
|
||||
|
||||
// SystemLog is the data of the system log: the tail of mail.log, newest line
|
||||
// first, in the one box that polls. The page and its fragment (GET
|
||||
// /server/log/fragment) render the same box (templates/system_log_body.html).
|
||||
type SystemLog struct {
|
||||
Meta
|
||||
Head Head
|
||||
|
||||
Log Box
|
||||
Lines []LogLine
|
||||
// Empty is shown in place of the lines when there are none: the log has
|
||||
// nothing in it yet, or it could not be read.
|
||||
Empty EmptyState
|
||||
}
|
||||
|
||||
// NewSystemLog builds the page. lines are the log's tail as it was read, oldest
|
||||
// first; the page shows them newest first. errText is why the log could not be
|
||||
// read, and empty when it could.
|
||||
func NewSystemLog(m Meta, lines []LogLine, errText string) *SystemLog {
|
||||
m.Title, m.Section, m.Page = "System log", "server", "log"
|
||||
p := &SystemLog{
|
||||
Meta: m,
|
||||
Head: Head{
|
||||
Kicker: "Server",
|
||||
Title: "System log",
|
||||
Lead: Rich("The tail of ", Code("mail.log"), " — Postfix, OpenDKIM and the milters. For one message, open it from the ", Link(outboundLog, "Outbound log"), " instead."),
|
||||
},
|
||||
Log: Box{No: "01", Title: "Recent lines", ID: "system-log", Poll: serverLog + "/fragment",
|
||||
End: Plain("Last 200 lines · refreshes on its own")},
|
||||
Empty: EmptyState{Icon: "ti-file-text", Text: Plain("No log lines yet.")},
|
||||
}
|
||||
for i := len(lines) - 1; i >= 0; i-- {
|
||||
p.Lines = append(p.Lines, lines[i])
|
||||
}
|
||||
if errText != "" {
|
||||
p.Empty = EmptyState{Icon: "ti-alert-circle", Text: Plain(errText)}
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// ---- Backup
|
||||
|
||||
// Backup is the data of the backup page: the whole instance in one archive, and
|
||||
// the import of one domain from another SelfPost. Both files are secrets and
|
||||
// both can be sealed with a password, so the page carries the password fields of
|
||||
// the full backup; the import asks for one only when the file needs it.
|
||||
type Backup struct {
|
||||
Meta
|
||||
Head Head
|
||||
Flash *Flash
|
||||
|
||||
Full Box
|
||||
FullAction string
|
||||
Encrypt bool
|
||||
PasswordMin int
|
||||
Import Box
|
||||
ImportAction string
|
||||
}
|
||||
|
||||
// NewBackup builds the page. The encryption box is ticked, as on the domain
|
||||
// export: a secret file is sealed unless the operator chooses otherwise, and
|
||||
// after a refusal the page comes back the same way. refusal is the message of
|
||||
// whichever form was turned down, empty on a first visit.
|
||||
func NewBackup(m Meta, passwordMin int, refusal string) *Backup {
|
||||
m.Title, m.Section, m.Page = "Backup", "server", "backup"
|
||||
p := &Backup{
|
||||
Meta: m,
|
||||
Head: Head{
|
||||
Kicker: "Server",
|
||||
Title: "Backup",
|
||||
Lead: Plain("Take the whole instance with you, or bring one domain in from another SelfPost. Both files are secrets."),
|
||||
},
|
||||
Full: Box{No: "01", Title: "Full backup"},
|
||||
FullAction: serverBackup,
|
||||
Encrypt: true,
|
||||
PasswordMin: passwordMin,
|
||||
Import: Box{No: "02", Title: "Import a domain", ID: "import"},
|
||||
ImportAction: serverBackup + "/import",
|
||||
}
|
||||
if refusal != "" {
|
||||
p.Flash = &Flash{Error: true, Text: Plain(refusal)}
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// ---- Users
|
||||
|
||||
// Users is the data of the list of panel users.
|
||||
type Users struct {
|
||||
Meta
|
||||
Head Head
|
||||
Flash *Flash
|
||||
|
||||
List Box
|
||||
Rows []UserRow
|
||||
ShowInbound bool
|
||||
}
|
||||
|
||||
// UserRow is one panel user as the list shows them: the name, with a mark on
|
||||
// the signed-in user's own row, the address, the role as a tag, the domains of
|
||||
// each direction as words and the way to the form.
|
||||
type UserRow struct {
|
||||
Name string
|
||||
You bool
|
||||
Email string
|
||||
Role Tag
|
||||
Outbound string
|
||||
Inbound string
|
||||
Href string
|
||||
}
|
||||
|
||||
// UserRowInput is what the list needs to know of one user. A global user
|
||||
// reaches every domain, so the lists of names are only read for the domain role.
|
||||
type UserRowInput struct {
|
||||
ID int64
|
||||
Username string
|
||||
Email string
|
||||
Global bool
|
||||
You bool
|
||||
AllOutbound bool
|
||||
AllInbound bool
|
||||
Outbound []string
|
||||
Inbound []string
|
||||
}
|
||||
|
||||
// NewUserRow words one user of the list.
|
||||
func NewUserRow(in UserRowInput) UserRow {
|
||||
role := Tag{Status: "domain"}
|
||||
if in.Global {
|
||||
role = Tag{Status: "global"}
|
||||
}
|
||||
return UserRow{
|
||||
Name: in.Username, You: in.You, Email: orDash(in.Email), Role: role,
|
||||
Outbound: reachWords(in.Global || in.AllOutbound, in.Outbound),
|
||||
Inbound: reachWords(in.Global || in.AllInbound, in.Inbound),
|
||||
Href: UserHref(in.ID),
|
||||
}
|
||||
}
|
||||
|
||||
// YouTag is the mark on the signed-in user's own row.
|
||||
func (r UserRow) YouTag() Tag { return Tag{Label: "you"} }
|
||||
|
||||
// reachWords is what the list says of one direction of a user's reach: All, the
|
||||
// names assigned, or a dash where nothing is.
|
||||
func reachWords(all bool, names []string) string {
|
||||
switch {
|
||||
case all:
|
||||
return "All"
|
||||
case len(names) > 0:
|
||||
return strings.Join(names, ", ")
|
||||
}
|
||||
return dash
|
||||
}
|
||||
|
||||
// NewUsers builds the page. inbound is whether the inbound feature is on, which
|
||||
// decides whether the list has the column and the lead speaks of it; flash is
|
||||
// the result of the last action.
|
||||
func NewUsers(m Meta, inbound bool, flash string) *Users {
|
||||
m.Title, m.Section, m.Page = "Users", "server", "users"
|
||||
reach := Rich("the domains assigned", Inline{Text: "."})
|
||||
if inbound {
|
||||
reach = Rich("the domains assigned, outbound and inbound separately", Inline{Text: "."})
|
||||
}
|
||||
p := &Users{
|
||||
Meta: m,
|
||||
Head: Head{
|
||||
Kicker: "Server",
|
||||
Title: "Users",
|
||||
Lead: Rich("Who may sign in to this panel. Everyone here is an administrator; the role is the reach — ",
|
||||
Em("global"), " is the whole server, ", Em("domain"), " is ", reach),
|
||||
Actions: []Action{{Label: "Create user", Icon: "ti-user-plus", Href: serverUsers + "/new", Primary: true}},
|
||||
},
|
||||
List: Box{No: "01", Title: "Panel users"},
|
||||
ShowInbound: inbound,
|
||||
}
|
||||
if flash != "" {
|
||||
p.Flash = &Flash{Text: Plain(flash)}
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// WithRows sets the users and counts them in the box head.
|
||||
func (p *Users) WithRows(rows []UserRow) *Users {
|
||||
p.Rows = rows
|
||||
p.List.End = Plain(plural(int64(len(rows)), "user", "users"))
|
||||
return p
|
||||
}
|
||||
|
||||
// ---- The user form
|
||||
|
||||
// UserForm is the data of the form that creates a panel user and edits one: the
|
||||
// account in one box, and the domains the user reaches, outbound and inbound
|
||||
// separately, in the other column. The same form serves both; a blank password
|
||||
// on an existing user means keep the current one.
|
||||
type UserForm struct {
|
||||
Meta
|
||||
Head Head
|
||||
Flash *Flash
|
||||
|
||||
// Action is where the form is sent, Submit what its button says and
|
||||
// CancelHref where Cancel goes.
|
||||
Action string
|
||||
Submit string
|
||||
CancelHref string
|
||||
|
||||
Account Box
|
||||
Username string
|
||||
Email string
|
||||
NewUser bool
|
||||
PasswordMin int
|
||||
Roles []Option
|
||||
Role string
|
||||
// RoleLocked is the only global user: the role cannot be changed, so the
|
||||
// select is read-only and the role travels in a hidden field.
|
||||
RoleLocked bool
|
||||
|
||||
Outbound Box
|
||||
AllOut bool
|
||||
OutDomains []DomainChoice
|
||||
|
||||
ShowInbound bool
|
||||
Inbound Box
|
||||
AllIn bool
|
||||
InDomains []DomainChoice
|
||||
}
|
||||
|
||||
// DomainChoice is one row of a list of domains on the user form.
|
||||
type DomainChoice struct {
|
||||
ID int64
|
||||
Name string
|
||||
Checked bool
|
||||
}
|
||||
|
||||
// UserFormInput is what a user form shows: a new user's blanks or an existing
|
||||
// user's values, or what was typed into a form that was refused.
|
||||
type UserFormInput struct {
|
||||
// ID is the user being edited, 0 for a new one; Name is that user's stored
|
||||
// name, which the head shows whatever was typed into the form.
|
||||
ID int64
|
||||
Name string
|
||||
|
||||
Username string
|
||||
Email string
|
||||
Role string
|
||||
RoleLocked bool
|
||||
// CanDelete says whether the head offers Delete user.
|
||||
CanDelete bool
|
||||
|
||||
PasswordMin int
|
||||
ShowInbound bool
|
||||
|
||||
AllOut bool
|
||||
OutDomains []DomainChoice
|
||||
AllIn bool
|
||||
InDomains []DomainChoice
|
||||
|
||||
Error string
|
||||
}
|
||||
|
||||
// NewUserForm builds the page.
|
||||
func NewUserForm(m Meta, in UserFormInput) *UserForm {
|
||||
crumbs := []Crumb{{Href: serverUsers, Label: "Users"}}
|
||||
title := "Create user"
|
||||
if in.ID != 0 {
|
||||
title = in.Name
|
||||
}
|
||||
m.Title, m.Section, m.Page = title+" · Users", "server", "users"
|
||||
p := &UserForm{
|
||||
Meta: m,
|
||||
Head: Head{Crumbs: crumbs, Title: title},
|
||||
CancelHref: serverUsers,
|
||||
|
||||
Account: Box{No: "01", Title: "Account"},
|
||||
Username: in.Username,
|
||||
Email: in.Email,
|
||||
NewUser: in.ID == 0,
|
||||
PasswordMin: in.PasswordMin,
|
||||
Roles: []Option{
|
||||
{Value: "domain", Label: "Domain — the domains assigned"},
|
||||
{Value: "global", Label: "Global — the whole server"},
|
||||
},
|
||||
Role: in.Role,
|
||||
RoleLocked: in.RoleLocked,
|
||||
|
||||
Outbound: Box{No: "02", Title: "Outbound domains", End: Plain("DNS records, applications, log")},
|
||||
AllOut: in.AllOut,
|
||||
OutDomains: in.OutDomains,
|
||||
ShowInbound: in.ShowInbound,
|
||||
Inbound: Box{No: "03", Title: "Inbound domains", End: Plain("Upstream, recipients, log")},
|
||||
AllIn: in.AllIn,
|
||||
InDomains: in.InDomains,
|
||||
}
|
||||
if p.NewUser {
|
||||
p.Action, p.Submit = serverUsers+"/new", "Create user"
|
||||
} else {
|
||||
p.Action, p.Submit = UserHref(in.ID), "Save user"
|
||||
if in.CanDelete {
|
||||
p.Head.Actions = []Action{{Label: "Delete user", Icon: "ti-trash", Href: UserHref(in.ID) + "/delete", Danger: true}}
|
||||
}
|
||||
}
|
||||
if in.Error != "" {
|
||||
p.Flash = &Flash{Error: true, Text: Plain(in.Error)}
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// ---- Delete a user
|
||||
|
||||
// UserDelete is the data of the confirmation before a panel user is deleted:
|
||||
// what goes with them, the one button that does it, and the way out.
|
||||
type UserDelete struct {
|
||||
Meta
|
||||
Head Head
|
||||
|
||||
Danger Box
|
||||
Intro Text
|
||||
Consequences []Text
|
||||
Note Text
|
||||
Action string
|
||||
Confirm string
|
||||
KeepHref string
|
||||
}
|
||||
|
||||
// UserDeleteInput is what the confirmation needs to know of the user.
|
||||
type UserDeleteInput struct {
|
||||
ID int64
|
||||
Username string
|
||||
Global bool
|
||||
// What the user is assigned: All of a direction, or the names of its domains.
|
||||
AllOutbound bool
|
||||
AllInbound bool
|
||||
Outbound []string
|
||||
Inbound []string
|
||||
// Following names the domains whose DMARC report address is this user's
|
||||
// default: with the user gone they have none.
|
||||
Following []string
|
||||
}
|
||||
|
||||
// NewUserDelete builds the page.
|
||||
func NewUserDelete(m Meta, in UserDeleteInput) *UserDelete {
|
||||
m.Title, m.Section, m.Page = "Delete "+in.Username, "server", "users"
|
||||
href := UserHref(in.ID)
|
||||
p := &UserDelete{
|
||||
Meta: m,
|
||||
Head: Head{
|
||||
Crumbs: []Crumb{{Href: serverUsers, Label: "Users"}, {Href: href, Label: in.Username}},
|
||||
Title: "Delete " + in.Username,
|
||||
},
|
||||
Danger: Box{Icon: "ti-alert-triangle", Title: "This cannot be undone", Variant: BoxDanger},
|
||||
Intro: Rich("Deleting the panel user ", Strong(in.Username), " will:"),
|
||||
Consequences: []Text{
|
||||
Plain("end any signed-in session of this user at once;"),
|
||||
},
|
||||
Note: Plain("Applications and their passwords, upstreams and recipients are not affected."),
|
||||
Action: href + "/delete",
|
||||
Confirm: "Delete " + in.Username,
|
||||
KeepHref: href,
|
||||
}
|
||||
if in.Global {
|
||||
p.Consequences = append(p.Consequences, Plain("remove their global access to the server and to every domain."))
|
||||
return p.withFollowing(in.Following)
|
||||
}
|
||||
var out, inb Text
|
||||
switch {
|
||||
case in.AllOutbound:
|
||||
out = Text{Strong("all outbound domains")}
|
||||
default:
|
||||
out = names(in.Outbound)
|
||||
}
|
||||
switch {
|
||||
case in.AllInbound:
|
||||
inb = Text{Strong("all inbound domains")}
|
||||
default:
|
||||
inb = names(in.Inbound)
|
||||
}
|
||||
switch {
|
||||
case len(out) > 0 && len(inb) > 0:
|
||||
p.Consequences = append(p.Consequences, Rich("remove the assignments to ", out, " (outbound) and to ", inb, " (inbound)."))
|
||||
case len(out) > 0:
|
||||
p.Consequences = append(p.Consequences, Rich("remove the assignments to ", out, " (outbound)."))
|
||||
case len(inb) > 0:
|
||||
p.Consequences = append(p.Consequences, Rich("remove the assignments to ", inb, " (inbound)."))
|
||||
}
|
||||
return p.withFollowing(in.Following)
|
||||
}
|
||||
|
||||
// followingShown is how many domain names the DMARC consequence spells out
|
||||
// before it says "and N more".
|
||||
const followingShown = 3
|
||||
|
||||
// withFollowing adds what deleting the user does to the domains that report to
|
||||
// their default address: they are left with none. Nothing is added when no
|
||||
// domain follows the user.
|
||||
func (p *UserDelete) withFollowing(domains []string) *UserDelete {
|
||||
if len(domains) == 0 {
|
||||
return p
|
||||
}
|
||||
shown, more := domains, 0
|
||||
if len(domains) > followingShown {
|
||||
shown, more = domains[:followingShown], len(domains)-followingShown
|
||||
}
|
||||
var list Text
|
||||
for i, n := range shown {
|
||||
switch {
|
||||
case i == 0:
|
||||
case i == len(shown)-1 && more == 0:
|
||||
list = append(list, Inline{Text: " and "})
|
||||
default:
|
||||
list = append(list, Inline{Text: ", "})
|
||||
}
|
||||
list = append(list, Strong(n))
|
||||
}
|
||||
if more > 0 {
|
||||
list = append(list, Inline{Text: " and " + strconv.Itoa(more) + " more"})
|
||||
}
|
||||
p.Consequences = append(p.Consequences, Rich(list, " will have no DMARC report address until someone sets one."))
|
||||
return p
|
||||
}
|
||||
|
||||
// names is a run of domain names, each strong, separated by commas.
|
||||
func names(list []string) Text {
|
||||
var out Text
|
||||
for i, n := range list {
|
||||
if i > 0 {
|
||||
out = append(out, Inline{Text: ", "})
|
||||
}
|
||||
out = append(out, Strong(n))
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
package view
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ---- System log
|
||||
|
||||
// The page shows the newest line first, and its one box is the element that
|
||||
// polls: the fragment answers with that same box.
|
||||
func TestSystemLogShowsNewestFirstAndPollsItsBox(t *testing.T) {
|
||||
out := renderSignedIn(t, "system-log", systemLogFixture())
|
||||
pageHas(t, "System log", out,
|
||||
`<h1 class="title is-3">System log</h1>`, `<h2>Recent lines</h2>`, `Last 200 lines · refreshes on its own`,
|
||||
`id="system-log" data-poll aria-live="polite" hx-get="/server/log/fragment" hx-trigger="load" hx-swap="outerHTML"`,
|
||||
`<span class="sp-d">Sep 21 14:01:52</span> postfix/smtp[2131]`,
|
||||
`<span class="sp-w">postfix/smtp[2123]`, `<span class="sp-e">postfix/smtpd[2119]`,
|
||||
)
|
||||
if strings.Index(out, "postfix/smtp[2131]") > strings.Index(out, "opendkim[39]") {
|
||||
t.Error("the log is not shown newest line first")
|
||||
}
|
||||
// What a line says is escaped, not interpreted.
|
||||
if !strings.Contains(out, "to=<m.keller@gmx.de>") || strings.Contains(out, "<m.keller@gmx.de>") {
|
||||
t.Error("an address in a log line is not escaped")
|
||||
}
|
||||
|
||||
frag := renderFragment(t, "system_log_body", systemLogFixture())
|
||||
if !strings.HasPrefix(frag, `<div class="box" id="system-log" data-poll`) || strings.Contains(frag, "sp-head") {
|
||||
t.Errorf("the fragment is not the polled box alone:\n%.200s", frag)
|
||||
}
|
||||
if strings.Count(out, `class="sp-log"`) != 1 || strings.Count(frag, `class="sp-log"`) != 1 {
|
||||
t.Error("the page and its fragment should each carry the log once")
|
||||
}
|
||||
}
|
||||
|
||||
// With nothing to show, the box says why instead: nothing logged yet, or the
|
||||
// log could not be read — and never both.
|
||||
func TestSystemLogSaysWhyItHasNoLines(t *testing.T) {
|
||||
out := renderSignedIn(t, "system-log", NewSystemLog(admin(), nil, ""))
|
||||
pageHas(t, "System log", out, `No log lines yet.`, `class="sp-empty"`)
|
||||
pageLacks(t, "System log", out, `class="sp-log"`, `Could not read`)
|
||||
|
||||
out = renderSignedIn(t, "system-log", NewSystemLog(admin(), nil, "Could not read the mail log."))
|
||||
pageHas(t, "System log", out, `Could not read the mail log.`, `ti-alert-circle`)
|
||||
pageLacks(t, "System log", out, `No log lines yet.`, `class="sp-log"`)
|
||||
}
|
||||
|
||||
// ---- Backup
|
||||
|
||||
// Both forms keep what they were: the encryption fields and their script hooks,
|
||||
// the multipart import and the anchor the domain page links to.
|
||||
func TestBackupHasBothFormsAndTheEncryptionHooks(t *testing.T) {
|
||||
out := renderSignedIn(t, "backup", backupFixture())
|
||||
pageHas(t, "Backup", out,
|
||||
`action="/server/backup"`, `action="/server/backup/import" enctype="multipart/form-data"`,
|
||||
`name="encrypt" value="1"`, `data-encrypt-toggle`, `data-encrypt-fields`,
|
||||
`name="password"`, `name="password_confirm"`, `minlength="12"`, `placeholder="at least 12 characters"`,
|
||||
`name="file"`, `accept=".json,.spde,application/json"`, `required data-import-file`,
|
||||
`data-import-password-fields`, `name="import_password"`, `<div class="box" id="import">`,
|
||||
)
|
||||
pageLacks(t, "Backup", out, `notification`)
|
||||
}
|
||||
|
||||
// The box is ticked on arrival, as on the domain export: the secret file is
|
||||
// sealed unless the operator unticks it, and the page comes back the same way
|
||||
// after a refusal, with the password fields in view.
|
||||
func TestBackupEncryptionIsTickedLikeTheDomainExport(t *testing.T) {
|
||||
backup := renderSignedIn(t, "backup", backupFixture())
|
||||
export := renderSignedIn(t, "out-domain-settings", outDomainSettingsFixture())
|
||||
const box = `name="encrypt" value="1" checked data-encrypt-toggle>`
|
||||
pageHas(t, "Backup", backup, box)
|
||||
pageHas(t, "Domain settings", export, box)
|
||||
for _, hook := range []string{`data-encrypt-fields`, `name="password" type="password" autocomplete="new-password" minlength="12" placeholder="at least 12 characters"`,
|
||||
`name="password_confirm" type="password" autocomplete="new-password"`,
|
||||
`Keep this password: SelfPost does not store it, and the file cannot be opened without it.`} {
|
||||
pageHas(t, "Backup", backup, hook)
|
||||
pageHas(t, "Domain settings", export, hook)
|
||||
}
|
||||
}
|
||||
|
||||
// A refusal is the flash above the boxes, with the encryption box still ticked.
|
||||
func TestBackupRefusalIsTheFlashAndKeepsTheTick(t *testing.T) {
|
||||
out := renderSignedIn(t, "backup", NewBackup(admin(), 12, "The two passwords do not match."))
|
||||
pageHas(t, "Backup", out, `<div class="notification is-danger is-light">`, `The two passwords do not match.`, `name="encrypt" value="1" checked`)
|
||||
if strings.Index(out, `class="notification`) > strings.Index(out, `class="box"`) {
|
||||
t.Error("the flash is not above the first box")
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Users
|
||||
|
||||
func TestUsersListsTheReachOfEachUser(t *testing.T) {
|
||||
out := renderSignedIn(t, "users", usersFixture())
|
||||
pageHas(t, "Users", out,
|
||||
`<h1 class="title is-3">Users</h1>`, `<span class="sp-no">01</span><h2>Panel users</h2>`, `3 users`,
|
||||
`href="/server/users/new"`, `Create user`, `<div class="notification is-success is-light">`, `User created.`,
|
||||
`<strong>admin</strong> <span class="tag is-light">you</span>`,
|
||||
`<span class="tag is-primary is-light">global</span>`, `<span class="tag is-light">domain</span>`,
|
||||
`<th>Outbound domains</th><th>Inbound domains</th>`,
|
||||
`<td class="sp-muted">shop.example.org</td><td class="sp-muted">—</td>`,
|
||||
`<td class="sp-muted">All</td><td class="sp-muted">acme.io</td>`,
|
||||
`mix@<wbr>example.org`, `team@<wbr>shop.example.org`, `href="/server/users/3"`,
|
||||
)
|
||||
if strings.Count(out, `>you<`) != 1 {
|
||||
t.Error("only the signed-in user's own row carries the mark")
|
||||
}
|
||||
|
||||
// Without the inbound feature the list has no inbound column, nor a lead
|
||||
// that speaks of one.
|
||||
p := NewUsers(admin(), false, "").WithRows([]UserRow{
|
||||
NewUserRow(UserRowInput{ID: 1, Username: "admin", Global: true, You: true}),
|
||||
})
|
||||
out = renderSignedIn(t, "users", p)
|
||||
pageHas(t, "Users", out, `1 user<`, `<th>Outbound domains</th><th></th>`)
|
||||
pageLacks(t, "Users", out, `<th>Inbound domains</th>`, `outbound and inbound separately`, `notification`)
|
||||
// A user who has not set an address is a dash, not a gap.
|
||||
pageHas(t, "Users", out, `<td class="sp-muted">—</td>`)
|
||||
}
|
||||
|
||||
func TestReachWords(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
all bool
|
||||
names []string
|
||||
want string
|
||||
}{
|
||||
{true, []string{"ignored.example"}, "All"},
|
||||
{false, []string{"a.example", "b.example"}, "a.example, b.example"},
|
||||
{false, nil, "—"},
|
||||
} {
|
||||
if got := reachWords(tt.all, tt.names); got != tt.want {
|
||||
t.Errorf("reachWords(%v, %v) = %q, want %q", tt.all, tt.names, got, tt.want)
|
||||
}
|
||||
}
|
||||
// A global user reaches everything whatever the lists say.
|
||||
row := NewUserRow(UserRowInput{ID: 1, Username: "root", Global: true})
|
||||
if row.Outbound != "All" || row.Inbound != "All" || row.Role.Status != "global" {
|
||||
t.Errorf("a global user's row = %+v", row)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- The user form
|
||||
|
||||
// The form for an existing user shows what is stored and sends everything the
|
||||
// handler reads under the names it has always read it by. Saving it untouched
|
||||
// sends the same values back: the role as selected, the All ticks as ticked, the
|
||||
// single domains as ticked, and a blank password, which the handler reads as
|
||||
// "keep".
|
||||
func TestUserFormShowsWhatIsStoredUnderTheHandlersFieldNames(t *testing.T) {
|
||||
out := renderSignedIn(t, "user", userFixture())
|
||||
pageHas(t, "User", out,
|
||||
`<h1 class="title is-3">acme-ops</h1>`, `href="/server/users">Users</a>`, `href="/server/users/3/delete"`,
|
||||
`<form class="sp-form" method="post" action="/server/users/3">`,
|
||||
`name="username" value="acme-ops"`, `<label class="label" for="email">E-mail</label>`,
|
||||
`<input class="input" id="email" name="email" type="email" value="ops@acme.io" autocomplete="off">`,
|
||||
`name="password" type="password" autocomplete="new-password">`,
|
||||
`Leave empty to keep the current one.`,
|
||||
`<select id="role" name="role">`, `<option value="domain" selected>`, `<option value="global">`,
|
||||
`name="all_domains" value="1" checked`, `name="domain_ids" value="1">`, `name="domain_ids" value="3">`,
|
||||
`name="all_inbound_domains" value="1">`, `name="inbound_domain_ids" value="1">`, `name="inbound_domain_ids" value="2" checked`,
|
||||
`>Save user</button>`, `href="/server/users">Cancel</a>`,
|
||||
)
|
||||
pageLacks(t, "User", out, `name="password" type="password" autocomplete="new-password" required`, `disabled`,
|
||||
`notification`, `type="hidden"`)
|
||||
if strings.Count(out, ` checked`) != 2 {
|
||||
t.Errorf("exactly the stored ticks are checked, got %d", strings.Count(out, ` checked`))
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserFormForANewUser(t *testing.T) {
|
||||
p := NewUserForm(admin(), UserFormInput{Role: "domain", PasswordMin: 12, ShowInbound: true,
|
||||
OutDomains: []DomainChoice{{ID: 1, Name: "example.org"}}})
|
||||
out := renderSignedIn(t, "user", p)
|
||||
pageHas(t, "User", out,
|
||||
`<h1 class="title is-3">Create user</h1>`, `action="/server/users/new"`, `At least 12 characters.`,
|
||||
`autocomplete="new-password" required>`, `>Create user</button>`, `<option value="domain" selected>`)
|
||||
pageLacks(t, "User", out, `/delete`, `Leave empty to keep`, ` checked`, `ops@acme.io`)
|
||||
pageHas(t, "User", out, `name="email" type="email" value=""`)
|
||||
}
|
||||
|
||||
// The only global user cannot be demoted: the select is read-only and the role
|
||||
// travels in a hidden field, so a save sends it. The head does not offer Delete.
|
||||
func TestUserFormOfTheOnlyGlobalUser(t *testing.T) {
|
||||
p := NewUserForm(admin(), UserFormInput{ID: 1, Name: "admin", Username: "admin", Role: "global", RoleLocked: true,
|
||||
PasswordMin: 12, ShowInbound: true})
|
||||
out := renderSignedIn(t, "user", p)
|
||||
pageHas(t, "User", out, `<input type="hidden" name="role" value="global">`, `<select id="role" name="role" disabled>`,
|
||||
`<option value="global" selected>`)
|
||||
pageLacks(t, "User", out, `/delete`, `Delete user`)
|
||||
}
|
||||
|
||||
// Inbound domains are offered only where the feature is on; the handler keeps
|
||||
// what the user already has there when the form has no such list.
|
||||
func TestUserFormHasNoInboundListWhenTheFeatureIsOff(t *testing.T) {
|
||||
p := NewUserForm(admin(), UserFormInput{ID: 2, Name: "shop-team", Username: "shop-team", Role: "domain", PasswordMin: 12})
|
||||
out := renderSignedIn(t, "user", p)
|
||||
pageHas(t, "User", out, `<h2>Outbound domains</h2>`)
|
||||
pageLacks(t, "User", out, `Inbound domains`, `all_inbound_domains`, `inbound_domain_ids`)
|
||||
}
|
||||
|
||||
// A form that was refused says so above the boxes, shows what was typed, and
|
||||
// names the user in the head as stored, not as typed.
|
||||
func TestUserFormRefusalKeepsWhatWasTyped(t *testing.T) {
|
||||
p := NewUserForm(admin(), UserFormInput{ID: 3, Name: "acme-ops", Username: "<b>x</b>", Email: `a"b@x`, Role: "global",
|
||||
PasswordMin: 12, Error: "That username is already in use."})
|
||||
out := renderSignedIn(t, "user", p)
|
||||
pageHas(t, "User", out, `<div class="notification is-danger is-light">`, `That username is already in use.`,
|
||||
`<h1 class="title is-3">acme-ops</h1>`, `value="<b>x</b>"`, `value="a"b@x"`, `<option value="global" selected>`)
|
||||
if strings.Index(out, `class="notification`) > strings.Index(out, `<form class="sp-form"`) {
|
||||
t.Error("the flash is not above the form")
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Delete a user
|
||||
|
||||
func TestUserDeleteNamesWhatGoesWithTheUser(t *testing.T) {
|
||||
out := renderSignedIn(t, "user-delete", userDeleteFixture())
|
||||
pageHas(t, "Delete user", out,
|
||||
`<h1 class="title is-3">Delete acme-ops</h1>`, `href="/server/users">Users</a>`, `href="/server/users/3">acme-ops</a>`,
|
||||
`<div class="box sp-danger-zone">`, `This cannot be undone`,
|
||||
`end any signed-in session of this user at once;`,
|
||||
`remove the assignments to <strong>all outbound domains</strong> (outbound) and to <strong>acme.io</strong> (inbound).`,
|
||||
`<form method="post" action="/server/users/3/delete">`, `Delete acme-ops</button>`, `href="/server/users/3">Keep it</a>`,
|
||||
)
|
||||
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
in UserDeleteInput
|
||||
want string
|
||||
}{
|
||||
{"global", UserDeleteInput{ID: 1, Username: "root", Global: true},
|
||||
`remove their global access to the server and to every domain.`},
|
||||
{"named domains", UserDeleteInput{ID: 2, Username: "u", Outbound: []string{"a.example", "b.example"}, AllInbound: true},
|
||||
`remove the assignments to <strong>a.example</strong>, <strong>b.example</strong> (outbound) and to <strong>all inbound domains</strong> (inbound).`},
|
||||
{"inbound only", UserDeleteInput{ID: 2, Username: "u", Inbound: []string{"in.example"}},
|
||||
`remove the assignments to <strong>in.example</strong> (inbound).`},
|
||||
} {
|
||||
got := renderSignedIn(t, "user-delete", NewUserDelete(admin(), tt.in))
|
||||
if !strings.Contains(got, tt.want) {
|
||||
t.Errorf("%s: missing %q in\n%s", tt.name, tt.want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Help
|
||||
|
||||
var helpLink = regexp.MustCompile(`class="sp-help" href="/help#([a-z-]+)"`)
|
||||
|
||||
// A box's Help link is only worth having if the Help page has the topic it
|
||||
// names. Every page of the panel is rendered with its fixture, and every anchor
|
||||
// it links to must be a topic of the Help page.
|
||||
func TestEveryHelpLinkLandsOnATopic(t *testing.T) {
|
||||
help := renderSignedIn(t, "help", NewHelp(admin(), true))
|
||||
linked := map[string][]string{}
|
||||
for name, fixture := range pageFixtures {
|
||||
for _, m := range helpLink.FindAllStringSubmatch(renderSignedIn(t, name, fixture()), -1) {
|
||||
linked[m[1]] = append(linked[m[1]], name)
|
||||
}
|
||||
}
|
||||
if len(linked) == 0 {
|
||||
t.Fatal("no page links to a Help topic, so nothing was checked")
|
||||
}
|
||||
for anchor, pages := range linked {
|
||||
if !strings.Contains(help, `class="sp-help-topic" id="`+anchor+`"`) {
|
||||
t.Errorf("%v link to /help#%s, which is not a topic of the Help page", pages, anchor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The menu of the Help page jumps to topics that exist, and every topic is in it.
|
||||
func TestHelpMenuAndTopicsAgree(t *testing.T) {
|
||||
for _, global := range []bool{true, false} {
|
||||
out := renderSignedIn(t, "help", NewHelp(admin(), global))
|
||||
topics := regexp.MustCompile(`class="sp-help-topic" id="([a-z-]+)"`).FindAllStringSubmatch(out, -1)
|
||||
menu := regexp.MustCompile(`<a href="#([a-z-]+)">`).FindAllStringSubmatch(out, -1)
|
||||
if len(topics) != len(menu) || len(topics) == 0 {
|
||||
t.Fatalf("global=%v: %d topics, %d menu entries", global, len(topics), len(menu))
|
||||
}
|
||||
for i := range topics {
|
||||
if topics[i][1] != menu[i][1] {
|
||||
t.Errorf("global=%v: topic %d is %q, its menu entry points at %q", global, i, topics[i][1], menu[i][1])
|
||||
}
|
||||
}
|
||||
unique(t, "Help", ids(out))
|
||||
}
|
||||
}
|
||||
|
||||
// The topics about Overview and Health are for the role that sees those pages;
|
||||
// a domain administrator is not shown checks they cannot open.
|
||||
func TestHelpLeavesOutTheOverviewForADomainAdministrator(t *testing.T) {
|
||||
out := renderSignedIn(t, "help", NewHelp(Meta{User: "ops", HasOutbound: true}, false))
|
||||
pageHas(t, "Help", out, `id="dns"`, `id="apps"`, `id="limits"`, `<h2>Outbound</h2>`)
|
||||
pageLacks(t, "Help", out, `id="checks"`, `id="certificate"`, `id="rdns"`, `<h2>Overview</h2>`, `href="/server/health"`)
|
||||
}
|
||||
|
||||
// The e-mail help says only what is so: it is the user's own address, they can
|
||||
// change it under Account, and it can receive DMARC reports if they choose.
|
||||
func TestUserFormEmailHelpSaysOnlyWhatIsTrue(t *testing.T) {
|
||||
out := renderSignedIn(t, "user", userFixture())
|
||||
pageHas(t, "User", out, `The user's own address. They can change it under Account, and can choose it there as the address that receives DMARC reports.`)
|
||||
pageLacks(t, "User", out, `notification`, `reset`)
|
||||
}
|
||||
|
||||
// With domains that follow the user's default report address, deleting them
|
||||
// leaves those domains with none, and the page says so: up to three names, then
|
||||
// the count of the rest. With none following, the page says nothing about it.
|
||||
func TestUserDeleteSaysWhichDomainsLoseTheirReportAddress(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
following []string
|
||||
want string
|
||||
}{
|
||||
{[]string{"example.org"}, `<strong>example.org</strong> will have no DMARC report address until someone sets one.`},
|
||||
{[]string{"example.org", "shop.example.org"}, `<strong>example.org</strong> and <strong>shop.example.org</strong> will have no DMARC report address until someone sets one.`},
|
||||
{[]string{"a.example", "b.example", "c.example"}, `<strong>a.example</strong>, <strong>b.example</strong> and <strong>c.example</strong> will have no DMARC report address`},
|
||||
{[]string{"a.example", "b.example", "c.example", "d.example", "e.example"}, `<strong>a.example</strong>, <strong>b.example</strong>, <strong>c.example</strong> and 2 more will have no DMARC report address`},
|
||||
} {
|
||||
in := UserDeleteInput{ID: 3, Username: "acme-ops", AllOutbound: true, Following: tt.following}
|
||||
out := renderSignedIn(t, "user-delete", NewUserDelete(admin(), in))
|
||||
pageHas(t, "Delete user", out, tt.want)
|
||||
if strings.Contains(out, "d.example") && len(tt.following) > 3 {
|
||||
t.Errorf("a fourth name is spelled out: %v", tt.following)
|
||||
}
|
||||
}
|
||||
// A global user's domains follow them as well.
|
||||
out := renderSignedIn(t, "user-delete", NewUserDelete(admin(), UserDeleteInput{ID: 2, Username: "root", Global: true, Following: []string{"example.org"}}))
|
||||
pageHas(t, "Delete user", out, `will have no DMARC report address`)
|
||||
|
||||
out = renderSignedIn(t, "user-delete", userDeleteFixture())
|
||||
pageLacks(t, "Delete user", out, `DMARC report address`, `The domains`)
|
||||
}
|
||||
@@ -1,43 +1,48 @@
|
||||
{{/* Backup (mockup backup.html): the whole instance in one archive, and one
|
||||
domain brought in from another SelfPost. Two boxes, each its own form and
|
||||
its own POST; a refusal of either comes back as the flash above them. The
|
||||
password fields of the full backup are shown when the box is ticked (panel.js
|
||||
reads data-encrypt-toggle / data-encrypt-fields); the import asks for a
|
||||
password only for an encrypted file (data-import-file /
|
||||
data-import-password-fields). Without scripts all of them are visible. */}}
|
||||
{{define "content"}}
|
||||
<h1>Backup & migration</h1>
|
||||
{{template "page_head" .Head}}
|
||||
{{if .Flash}}{{template "flash" .Flash}}{{end}}
|
||||
|
||||
<div class="card">
|
||||
<h2>Full backup</h2>
|
||||
<p class="muted">Download a self-contained backup of the whole instance —
|
||||
<code>data/</code> (database, DKIM keys, application credentials, and the
|
||||
Postfix queue), <code>docker-compose.yml</code>, <code>.env</code>, and
|
||||
<code>certs/</code>. Extract it into an empty project directory on a new
|
||||
machine, adjust hostname or proxy settings if needed, and start a container of
|
||||
the <strong>same SelfPost version</strong> before first boot. The reverse-proxy
|
||||
vhost is not included — set that up separately on the new host.</p>
|
||||
<p class="muted"><strong>The backup file is a secret</strong> (it contains
|
||||
private keys, TLS material, and credentials). Store and transfer it securely and delete it
|
||||
once the restore succeeds. Encrypting it below is the simplest way to do that:
|
||||
the download is then a <code>.spbk</code> file (SelfPost backup) that only
|
||||
the password opens.</p>
|
||||
{{if .BackupErr}}<p class="error">{{.BackupErr}}</p>{{end}}
|
||||
<form method="post" action="/server/backup">
|
||||
{{template "encryptfields" .}}
|
||||
<button type="submit">Download full backup</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Import a domain</h2>
|
||||
<p class="muted">Move a single domain here from another SelfPost instance using
|
||||
a domain export file (from that domain's page) — plain <code>.json</code> or
|
||||
encrypted <code>.spde</code> (SelfPost domain export). Its DKIM key and
|
||||
application passwords come across, so the published DNS record needs no
|
||||
change. The export file is a secret, like a full backup.</p>
|
||||
{{if .ImportErr}}<p class="error">{{.ImportErr}}</p>{{end}}
|
||||
<form method="post" action="/server/backup/import" enctype="multipart/form-data">
|
||||
<label for="importfile">Domain export file</label>
|
||||
<input id="importfile" name="file" type="file" accept=".json,.spde,application/json" required data-import-file>
|
||||
<div class="encrypt-fields" data-import-password-fields>
|
||||
<label for="importpw">Password</label>
|
||||
<input id="importpw" name="import_password" type="password" autocomplete="off">
|
||||
<div class="columns">
|
||||
<div class="column is-6">
|
||||
{{template "box_open" .Full}}
|
||||
{{template "body_open"}}
|
||||
<p class="mb-3"><code>data/</code> (database, DKIM keys, credentials, the queue), <code>docker-compose.yml</code>, <code>.env</code> and <code>certs/</code> in one archive. Restore into an empty project directory and start the <strong>same SelfPost version</strong>. The reverse-proxy vhost is not included.</p>
|
||||
<form class="sp-form" method="post" action="{{.FullAction}}">
|
||||
<div class="field"><label class="checkbox"><input type="checkbox" name="encrypt" value="1"{{if .Encrypt}} checked{{end}} data-encrypt-toggle> Encrypt with a password (<code>.spbk</code>)</label></div>
|
||||
<div data-encrypt-fields>
|
||||
<div class="sp-pair">
|
||||
<div class="field"><label class="label" for="encpw">Password</label><div class="control"><input class="input" id="encpw" name="password" type="password" autocomplete="new-password" minlength="{{.PasswordMin}}" placeholder="at least {{.PasswordMin}} characters"></div></div>
|
||||
<div class="field"><label class="label" for="encpw2">Repeat password</label><div class="control"><input class="input" id="encpw2" name="password_confirm" type="password" autocomplete="new-password"></div></div>
|
||||
</div>
|
||||
<button type="submit">Import domain</button>
|
||||
</form>
|
||||
<p class="help mb-3">Keep this password: SelfPost does not store it, and the file cannot be opened without it.</p>
|
||||
</div>
|
||||
<button type="submit" class="button is-primary"><i class="ti ti-download"></i>Download full backup</button>
|
||||
</form>
|
||||
{{template "body_close"}}
|
||||
{{template "box_close"}}
|
||||
</div>
|
||||
|
||||
<div class="column is-6">
|
||||
{{template "box_open" .Import}}
|
||||
{{template "body_open"}}
|
||||
<p class="mb-3">A domain export from another instance — plain <code>.json</code> or encrypted <code>.spde</code>. Its DKIM key and application passwords come across, so the published DNS needs no change. The export itself is made on the domain's settings page.</p>
|
||||
<form class="sp-form" method="post" action="{{.ImportAction}}" enctype="multipart/form-data">
|
||||
<div class="field"><label class="label" for="importfile">Domain export file</label>
|
||||
<div class="control"><input class="input" id="importfile" name="file" type="file" accept=".json,.spde,application/json" required data-import-file></div></div>
|
||||
<div class="field" data-import-password-fields><label class="label" for="importpw">Password</label>
|
||||
<div class="control"><input class="input" id="importpw" name="import_password" type="password" autocomplete="off"></div>
|
||||
<p class="help">Asked only for an encrypted <code>.spde</code> file.</p></div>
|
||||
<button type="submit" class="button is-primary"><i class="ti ti-file-import"></i>Import domain</button>
|
||||
</form>
|
||||
{{template "body_close"}}
|
||||
{{template "box_close"}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
{{/* Password fields shared by the full-backup and domain-export forms. The
|
||||
checkbox is the switch: unticked, the download keeps its historic plain
|
||||
form (.tar.gz / .json); ticked, the file is sealed in a password-encrypted
|
||||
envelope (.spbk = SelfPost backup, .spde = SelfPost domain export). The
|
||||
fields start hidden and are revealed by panel.js — with JavaScript blocked
|
||||
they are simply always visible, and the server still decides from the
|
||||
checkbox alone. */}}
|
||||
{{define "encryptfields"}}
|
||||
<div class="encrypt">
|
||||
<label class="check">
|
||||
<input type="checkbox" name="encrypt" value="1" data-encrypt-toggle>
|
||||
<span>Encrypt with a password</span>
|
||||
</label>
|
||||
<div class="encrypt-fields" data-encrypt-fields>
|
||||
<label for="encpw">Password</label>
|
||||
<input id="encpw" name="password" type="password" autocomplete="new-password"
|
||||
minlength="{{.MinPwLen}}" placeholder="at least {{.MinPwLen}} characters">
|
||||
<label for="encpw2">Repeat password</label>
|
||||
<input id="encpw2" name="password_confirm" type="password" autocomplete="new-password">
|
||||
<p class="muted">Keep this password: without it the file cannot be opened,
|
||||
and SelfPost does not store it anywhere.</p>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -1,62 +1,25 @@
|
||||
{{/* Help (mockup help.html): one topic per heading, stacked in two boxes, with a
|
||||
menu that jumps to each. A box on another page links to the topic that
|
||||
explains it by the topic's id (Box.Help). Overview is absent for a viewer
|
||||
who does not see the pages it explains. */}}
|
||||
{{define "content"}}
|
||||
<h1>Help</h1>
|
||||
{{template "page_head" .Head}}
|
||||
|
||||
<p class="muted">Short operator notes inside the panel — not a second copy of
|
||||
the full guide. The cards keep their readings and controls; this page (and the
|
||||
drawer opened from each card's «?») holds the explanations that do not belong
|
||||
on the card itself.</p>
|
||||
|
||||
<div class="card">
|
||||
<h2>On this panel</h2>
|
||||
{{if .IsGlobal}}
|
||||
<ul>
|
||||
<li><label for="help-status">Status checks</label> — Machine, TLS, PTR,
|
||||
queue</li>
|
||||
<li><a href="/outbound/queue">Mail queue retries</a> — time-based, no attempt
|
||||
budget (on the queue page)</li>
|
||||
{{if .InboundEnabled}}
|
||||
<li><a href="/inbound/domains">Inbound</a> — not mailboxes; listed recipients or any
|
||||
address at the domain</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{end}}
|
||||
<p class="muted">Domain page</p>
|
||||
<ul>
|
||||
<li><label for="help-dns">DNS status</label></li>
|
||||
<li><label for="help-records">DKIM and SPF records</label></li>
|
||||
<li><label for="help-dmarc">DMARC record</label></li>
|
||||
<li><label for="help-connection">Connection settings</label></li>
|
||||
<li><label for="help-apps">Applications</label></li>
|
||||
<li><label for="help-domain-settings">Domain settings</label></li>
|
||||
<li><label for="help-export">Export domain</label></li>
|
||||
</ul>
|
||||
<p class="muted">Topics marked as labels open the same text in the help
|
||||
drawer. For installation, backup, and environment variables see the
|
||||
<a href="{{.SourceURL}}/blob/main/docs/guide.md">operator
|
||||
guide</a> in the repository.</p>
|
||||
</div>
|
||||
|
||||
{{if .IsGlobal}}
|
||||
<div class="card">
|
||||
<h2>Machine</h2>
|
||||
<p>CPU and memory are the container's own readings, not the host's spare
|
||||
capacity. Network is a short window, not a daily total. High CPU with an empty
|
||||
queue usually means something else on the box — not SelfPost being slow to
|
||||
send.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>TLS certificate</h2>
|
||||
<p>Port 465 presents the certificate the reverse proxy (or the image)
|
||||
mounted. The panel does not issue certificates. A warn here is expires soon;
|
||||
an error is missing or unreadable, and clients will refuse submission.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Hostname / reverse DNS</h2>
|
||||
<p>Forward-confirmed reverse DNS: the A/AAAA for
|
||||
<code>SELFPOST_HOSTNAME</code> must reverse to that same name. PTR is set at
|
||||
the hosting provider, not in this panel.</p>
|
||||
<div class="columns">
|
||||
<div class="column is-3">
|
||||
{{template "side_menu" .Menu}}
|
||||
</div>
|
||||
<div class="column is-9">
|
||||
{{if .ShowOverview}}
|
||||
{{template "box_open" .Overview}}
|
||||
{{range .OverviewTopics}}{{template "help_topic" .}}
|
||||
{{end}}
|
||||
{{template "box_close"}}
|
||||
{{end}}
|
||||
{{template "box_open" .Outbound}}
|
||||
{{range .OutboundTopics}}{{template "help_topic" .}}
|
||||
{{end}}
|
||||
{{template "box_close"}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
@@ -1,161 +0,0 @@
|
||||
{{/* Hidden radios drive which help pane is visible — CSS :has(), no script. */}}
|
||||
{{define "help_inputs"}}
|
||||
<input class="ctrl" type="radio" name="help" id="help-off" checked>
|
||||
<input class="ctrl" type="radio" name="help" id="help-index">
|
||||
<input class="ctrl" type="radio" name="help" id="help-status">
|
||||
<input class="ctrl" type="radio" name="help" id="help-password">
|
||||
<input class="ctrl" type="radio" name="help" id="help-dns">
|
||||
<input class="ctrl" type="radio" name="help" id="help-records">
|
||||
<input class="ctrl" type="radio" name="help" id="help-dmarc">
|
||||
<input class="ctrl" type="radio" name="help" id="help-connection">
|
||||
<input class="ctrl" type="radio" name="help" id="help-apps">
|
||||
<input class="ctrl" type="radio" name="help" id="help-domain-settings">
|
||||
<input class="ctrl" type="radio" name="help" id="help-export">
|
||||
{{end}}
|
||||
|
||||
{{define "help_more"}}<p class="more muted"><label for="help-index">All topics</label>{{if .}} · <a href="/help">Help page</a>{{end}}</p>{{end}}
|
||||
|
||||
{{/* card_help — «?» on a card head; .ID is the topic slug (dns, records, …). */}}
|
||||
{{define "card_help"}}
|
||||
<label class="help-link" for="help-{{.ID}}" title="{{.Title}}">?</label>
|
||||
{{end}}
|
||||
|
||||
{{define "help_drawer"}}
|
||||
<label class="help-scrim" for="help-off" role="button" aria-label="Close help"></label>
|
||||
<aside class="help-drawer">
|
||||
<label class="help-close" for="help-off" role="button" aria-label="Close help">Close</label>
|
||||
|
||||
<article class="help-pane help-pane-index">
|
||||
<h2>Help</h2>
|
||||
<p>Short notes for the card you opened — not a second copy of the
|
||||
<a href="{{.SourceURL}}/blob/main/docs/guide.md">operator guide</a>.</p>
|
||||
{{if .IsGlobal}}
|
||||
<p class="muted">Status</p>
|
||||
<ul class="toc"><li><label for="help-status">Status checks</label></li></ul>
|
||||
{{end}}
|
||||
<p class="muted">Domain</p>
|
||||
<ul class="toc">
|
||||
<li><label for="help-password">New application password</label></li>
|
||||
<li><label for="help-dns">DNS status</label></li>
|
||||
<li><label for="help-records">DKIM and SPF records</label></li>
|
||||
<li><label for="help-dmarc">DMARC record</label></li>
|
||||
<li><label for="help-connection">Connection settings</label></li>
|
||||
<li><label for="help-apps">Applications</label></li>
|
||||
<li><label for="help-domain-settings">Domain settings</label></li>
|
||||
<li><label for="help-export">Export domain</label></li>
|
||||
</ul>
|
||||
</article>
|
||||
|
||||
<article class="help-pane help-pane-status">
|
||||
<h2>Status checks</h2>
|
||||
<p>The cards keep the readings. This drawer is what used to sit under them
|
||||
as paragraphs.</p>
|
||||
<h2>Machine</h2>
|
||||
<p>CPU and memory are the container's readings, not the host's spare
|
||||
capacity. Network is a short rate window between refreshes, not a daily
|
||||
total. High CPU with an empty queue usually means something else on the box
|
||||
— not SelfPost being slow to send.</p>
|
||||
<h2>TLS certificate</h2>
|
||||
<p>Port 465 presents the certificate the reverse proxy (or the image)
|
||||
mounted. The panel does not issue certificates. A warn here is expires
|
||||
soon; an error is missing or unreadable, and clients will refuse
|
||||
submission.</p>
|
||||
<h2>Hostname / reverse DNS</h2>
|
||||
<p>Forward-confirmed reverse DNS: the A/AAAA for
|
||||
<code>SELFPOST_HOSTNAME</code> must reverse to that same name. PTR is set
|
||||
at the hosting provider, not in this panel. Receiving networks use this
|
||||
pair as a cheap reputation check.</p>
|
||||
<h2>Mail queue</h2>
|
||||
<p>Deferred mail is retried on a time schedule (first delay, doubling cap,
|
||||
queue lifetime). There is no attempt budget — see the retry-policy card on
|
||||
<em>Mail queue</em>.</p>
|
||||
{{if .InboundEnabled}}
|
||||
<h2>Inbound</h2>
|
||||
<p>When <code>INBOUND_RELAY_ENABLE</code> is on, port 25 accepts mail for
|
||||
configured domains and forwards it upstream — not to local mailboxes.
|
||||
Recipients are a list or any address at the domain.</p>
|
||||
{{end}}
|
||||
{{template "help_more" true}}
|
||||
</article>
|
||||
|
||||
<article class="help-pane help-pane-password">
|
||||
<h2>New application password</h2>
|
||||
<p>Shown <strong>once only</strong> and not stored. Copy it now — if it is
|
||||
lost, regenerate a new one. The previous password stops working
|
||||
immediately.</p>
|
||||
{{template "help_more" true}}
|
||||
</article>
|
||||
|
||||
<article class="help-pane help-pane-dns">
|
||||
<h2>DNS status</h2>
|
||||
<p>The badge is the worst of DKIM, SPF and DMARC. Results are cached a few
|
||||
minutes — use <em>Re-check</em> after publishing.</p>
|
||||
<p>SPF is a shallow check: the literal address only, no
|
||||
<code>include:</code> or <code>redirect=</code>. Report authorization is
|
||||
required only when <code>rua=</code> points at a domain this server does
|
||||
not accept.</p>
|
||||
{{template "help_more" true}}
|
||||
</article>
|
||||
|
||||
<article class="help-pane help-pane-records">
|
||||
<h2>DKIM and SPF records</h2>
|
||||
<p>DKIM is not a secret. The selector on this page is the one this server
|
||||
signs with. Merge the SPF example into an existing record if the domain
|
||||
already has one — do not publish a second TXT.</p>
|
||||
{{template "help_more" true}}
|
||||
</article>
|
||||
|
||||
<article class="help-pane help-pane-dmarc">
|
||||
<h2>DMARC record</h2>
|
||||
<p><code>p=none</code> does not affect delivery. Tighten to
|
||||
<code>p=quarantine</code> then <code>p=reject</code> once reports look
|
||||
clean. The report address is set under Domain settings (or the Settings
|
||||
default).</p>
|
||||
{{template "help_more" true}}
|
||||
</article>
|
||||
|
||||
<article class="help-pane help-pane-connection">
|
||||
<h2>Connection settings</h2>
|
||||
<p>Same host for every domain. Authenticate with an application login from
|
||||
this page. Auth is required on every port. The password is shown once at
|
||||
create or regenerate.</p>
|
||||
<p>465 is implicit TLS; 587 is STARTTLS submission when that port is
|
||||
enabled.</p>
|
||||
{{template "help_more" true}}
|
||||
</article>
|
||||
|
||||
<article class="help-pane help-pane-apps">
|
||||
<h2>Applications</h2>
|
||||
<p>SASL logins for this domain. Login is unique across domains; letters,
|
||||
digits, <code>.</code>, <code>-</code> and <code>_</code>. The password is
|
||||
shown once.</p>
|
||||
<p>Address mode is which From addresses this application may use: any
|
||||
address of the domain, or a fixed list. Client IP allow-list, when enabled,
|
||||
restricts which client addresses may <em>submit</em> as this application;
|
||||
when off, any client IP is allowed. It is checked in the journal-milter at
|
||||
<code>MAIL FROM</code>, alongside the level-2 limit and fail-open like it,
|
||||
so it narrows sending rather than blocking the SASL login itself. The
|
||||
level-2 rate limit <em>overrides</em> the
|
||||
domain limit for that application — you can set a ceiling above or below
|
||||
the domain (still ≤ level 1). Independent of the client IP allow-list.</p>
|
||||
{{template "help_more" true}}
|
||||
</article>
|
||||
|
||||
<article class="help-pane help-pane-domain-settings">
|
||||
<h2>Domain settings</h2>
|
||||
<p>Aggregate reports (<code>rua=</code>) inherit the Settings default, or
|
||||
you override them per domain. Level 2 is an optional ceiling for all
|
||||
senders on this domain; it must be ≤ level 1. Each application may override
|
||||
that domain limit (higher or lower, still ≤ level 1).</p>
|
||||
{{template "help_more" true}}
|
||||
</article>
|
||||
|
||||
<article class="help-pane help-pane-export">
|
||||
<h2>Export domain</h2>
|
||||
<p>The file is a secret: it carries the DKIM key and application
|
||||
passwords, so published DNS does not have to change on the other instance.
|
||||
Transfer it securely, or encrypt it as <code>.spde</code>.</p>
|
||||
{{template "help_more" true}}
|
||||
</article>
|
||||
</aside>
|
||||
{{end}}
|
||||
@@ -26,7 +26,6 @@
|
||||
<script src="/static/panel.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
{{if .User}}{{template "help_inputs" .}}{{end}}
|
||||
{{/* Two columns: the navigation on the left and the page beside it, centred
|
||||
together (see .shell in legacy.css). The signed-out pages have no navigation
|
||||
and the shell holds their card alone. */}}
|
||||
@@ -55,7 +54,6 @@
|
||||
</footer>
|
||||
</main>
|
||||
</div>
|
||||
{{if .User}}{{template "help_drawer" .}}{{end}}
|
||||
</body>
|
||||
</html>{{end}}
|
||||
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
{{/* Raw mail.log lines, long by nature: the whole column rather than the
|
||||
reading measure (see the "wide" block in layout.html). */}}
|
||||
{{define "wide"}}wide{{end}}
|
||||
|
||||
{{/* System log (mockup system-log.html): the tail of mail.log in one box. The box
|
||||
is the polled part (system_log_body.html), so the page and its fragment
|
||||
draw it from the same block. */}}
|
||||
{{define "content"}}
|
||||
<h1>System log</h1>
|
||||
{{template "page_head" .Head}}
|
||||
|
||||
<div class="card">
|
||||
<h2>Recent log entries</h2>
|
||||
{{template "system_log_body" .}}
|
||||
</div>
|
||||
{{template "system_log_body" .}}
|
||||
{{end}}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
{{define "system_log_body"}}
|
||||
<div id="system-log-body" data-poll aria-live="polite" hx-get="/server/log/fragment" hx-trigger="load" hx-swap="outerHTML">
|
||||
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
|
||||
<span class="code">{{if .Lines}}{{range .Lines}}{{.}}
|
||||
{{end}}{{else}}No log lines yet.{{end}}</span>
|
||||
</div>
|
||||
{{end}}
|
||||
{{/* The polled part of the System log, in the page and in its fragment (GET
|
||||
/server/log/fragment) alike: the box itself polls (.Log.Poll), so the
|
||||
response replaces it whole. With no lines to show it says why instead. */}}
|
||||
{{define "system_log_body"}}{{template "box_open" .Log}}
|
||||
{{if .Lines}}{{template "log_pane" .Lines}}{{else}}{{template "empty_state" .Empty}}{{end}}
|
||||
{{template "box_close"}}{{end}}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
{{/* The user form (mockup user.html): one form that creates a panel user and
|
||||
edits one. The account is the left column; on the right the domains the user
|
||||
reaches, outbound and inbound as two lists, each with its All. The lists stay
|
||||
in view whatever the role — the server ignores them for the global role. A
|
||||
blank password keeps the current one. */}}
|
||||
{{define "content"}}
|
||||
{{template "page_head" .Head}}
|
||||
{{if .Flash}}{{template "flash" .Flash}}{{end}}
|
||||
|
||||
<form class="sp-form" method="post" action="{{.Action}}">
|
||||
<div class="columns">
|
||||
<div class="column is-6">
|
||||
{{template "box_open" .Account}}
|
||||
{{template "body_open"}}
|
||||
<div class="field"><label class="label" for="username">Username</label><div class="control"><input class="input" id="username" name="username" value="{{.Username}}" autocomplete="username" autocapitalize="none" spellcheck="false" required></div></div>
|
||||
<div class="field"><label class="label" for="email">E-mail</label><div class="control"><input class="input" id="email" name="email" type="email" value="{{.Email}}" autocomplete="off"></div>
|
||||
<p class="help">The user's own address. They can change it under Account, and can choose it there as the address that receives DMARC reports.</p></div>
|
||||
<div class="field"><label class="label" for="password">Password</label><div class="control"><input class="input" id="password" name="password" type="password" autocomplete="new-password"{{if .NewUser}} required{{end}}></div>
|
||||
<p class="help">{{if .NewUser}}At least {{.PasswordMin}} characters.{{else}}Leave empty to keep the current one.{{end}}</p></div>
|
||||
<div class="field"><label class="label" for="role">Role</label>
|
||||
{{if .RoleLocked}}<input type="hidden" name="role" value="{{.Role}}">{{end}}
|
||||
<div class="control"><div class="select is-fullwidth"><select id="role" name="role"{{if .RoleLocked}} disabled{{end}}>{{$sel := .Role}}{{range .Roles}}
|
||||
<option value="{{.Value}}"{{if eq .Value $sel}} selected{{end}}>{{.Label}}</option>{{end}}
|
||||
</select></div></div>
|
||||
<p class="help">Everyone here is an administrator; the role is the reach. <em>Global</em> also sees Server and is the only one who adds or deletes domains. The only global user cannot be changed to <em>domain</em> or deleted.</p></div>
|
||||
{{template "body_close"}}
|
||||
{{template "box_close"}}
|
||||
</div>
|
||||
<div class="column is-6">
|
||||
{{template "box_open" .Outbound}}
|
||||
{{template "body_open"}}
|
||||
<div class="field"><label class="checkbox"><input type="checkbox" name="all_domains" value="1"{{if .AllOut}} checked{{end}}> <strong>All outbound domains</strong>, including ones added later</label></div>{{range .OutDomains}}
|
||||
<div class="field"><label class="checkbox"><input type="checkbox" name="domain_ids" value="{{.ID}}"{{if .Checked}} checked{{end}}> {{.Name}}</label></div>{{end}}
|
||||
<p class="help">With <em>All</em> ticked the single domains are ignored.</p>
|
||||
{{template "body_close"}}
|
||||
{{template "box_close"}}
|
||||
{{if .ShowInbound}}
|
||||
{{template "box_open" .Inbound}}
|
||||
{{template "body_open"}}
|
||||
<div class="field"><label class="checkbox"><input type="checkbox" name="all_inbound_domains" value="1"{{if .AllIn}} checked{{end}}> <strong>All inbound domains</strong>, including ones added later</label></div>{{range .InDomains}}
|
||||
<div class="field"><label class="checkbox"><input type="checkbox" name="inbound_domain_ids" value="{{.ID}}"{{if .Checked}} checked{{end}}> {{.Name}}</label></div>{{end}}
|
||||
<p class="help">Granted separately from outbound: the same name on both lists is two assignments. The <em>domain</em> role needs at least one domain, or <em>All</em>, on either list.</p>
|
||||
{{template "body_close"}}
|
||||
{{template "box_close"}}
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="buttons">
|
||||
<button type="submit" class="button is-primary">{{.Submit}}</button>
|
||||
<a class="button is-text" href="{{.CancelHref}}">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
{{end}}
|
||||
@@ -1,16 +1,23 @@
|
||||
{{/* Delete a panel user (mockup user-delete.html): what goes with the user, the
|
||||
one button that does it, and the way back to the user's form. */}}
|
||||
{{define "content"}}
|
||||
<h1>Delete {{.TargetUsername}}</h1>
|
||||
{{template "page_head" .Head}}
|
||||
|
||||
{{template "back_link" (back (printf "/server/users/%d" .TargetID) (printf "Back to %s" .TargetUsername))}}
|
||||
|
||||
<div class="card narrow">
|
||||
<h2>Confirm deletion</h2>
|
||||
<p>You are about to delete the panel user <strong>{{.TargetUsername}}</strong>.
|
||||
This cannot be undone; a signed-in session for this user stops working
|
||||
immediately.</p>
|
||||
|
||||
<form method="post" action="/server/users/{{.TargetID}}/delete">
|
||||
<button type="submit" class="danger">Delete {{.TargetUsername}}</button>
|
||||
</form>
|
||||
<div class="columns">
|
||||
<div class="column is-7">
|
||||
{{template "box_open" .Danger}}
|
||||
{{template "body_open"}}
|
||||
<p class="mb-3">{{template "inline" .Intro}}</p>
|
||||
{{template "confirm_list" .Consequences}}
|
||||
<p class="sp-muted mb-4">{{template "inline" .Note}}</p>
|
||||
<form method="post" action="{{.Action}}">
|
||||
<div class="buttons">
|
||||
<button type="submit" class="button is-danger"><i class="ti ti-trash"></i>{{.Confirm}}</button>
|
||||
<a class="button is-text" href="{{.KeepHref}}">Keep it</a>
|
||||
</div>
|
||||
</form>
|
||||
{{template "body_close"}}
|
||||
{{template "box_close"}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
{{define "content"}}
|
||||
<h1>{{if .IsEdit}}Edit user{{else}}Create user{{end}}</h1>
|
||||
|
||||
{{template "back_link" (back "/server/users" "Back to users")}}
|
||||
|
||||
<div class="card narrow">
|
||||
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
|
||||
<form method="post" action="{{if .IsEdit}}/server/users/{{.UserID}}{{else}}/server/users/new{{end}}">
|
||||
<label for="username">Username</label>
|
||||
<input id="username" name="username" autocomplete="username"
|
||||
autocapitalize="none" spellcheck="false" value="{{.FormUsername}}" required>
|
||||
|
||||
<label for="password">Password{{if .IsEdit}} (leave empty to keep){{end}}</label>
|
||||
<input id="password" name="password" type="password" autocomplete="new-password" {{if not .IsEdit}}required{{end}}>
|
||||
|
||||
<label for="role">Role</label>
|
||||
{{if .LastGlobalLocked}}<input type="hidden" name="role" value="{{.FormRole}}">{{end}}
|
||||
<select id="role" name="role" data-global-role="{{.GlobalRole}}"{{if .LastGlobalLocked}} disabled{{end}}>
|
||||
<option value="domain" {{if eq .FormRole "domain"}}selected{{end}}>Domain administrator</option>
|
||||
<option value="global" {{if eq .FormRole "global"}}selected{{end}}>Global administrator</option>
|
||||
</select>
|
||||
{{if .LastGlobalLocked}}<p class="muted">The only global administrator cannot be demoted.</p>{{end}}
|
||||
|
||||
{{/* Outbound and inbound domains are assigned separately; a domain
|
||||
administrator needs at least one on either list. "All" also covers
|
||||
domains added later — with it ticked the rows below are ignored. */}}
|
||||
<div id="domain-pick" data-domain-pick>
|
||||
<fieldset>
|
||||
<legend>Outbound domains</legend>
|
||||
<p class="muted">A domain administrator needs at least one outbound or inbound domain.</p>
|
||||
<label class="check">
|
||||
<input type="checkbox" name="all_domains" value="1" {{if .FormAllDomains}}checked{{end}}>
|
||||
All, including domains added later
|
||||
</label>
|
||||
{{range .Domains}}
|
||||
<label class="check">
|
||||
<input type="checkbox" name="domain_ids" value="{{.ID}}" {{if index $.FormDomains .ID}}checked{{end}}>
|
||||
{{.Name}}
|
||||
</label>
|
||||
{{end}}
|
||||
</fieldset>
|
||||
{{if .InboundEnabled}}
|
||||
<fieldset>
|
||||
<legend>Inbound domains</legend>
|
||||
<label class="check">
|
||||
<input type="checkbox" name="all_inbound_domains" value="1" {{if .FormAllInbound}}checked{{end}}>
|
||||
All, including domains added later
|
||||
</label>
|
||||
{{range .InboundDomains}}
|
||||
<label class="check">
|
||||
<input type="checkbox" name="inbound_domain_ids" value="{{.ID}}" {{if index $.FormInbound .ID}}checked{{end}}>
|
||||
{{.Name}}
|
||||
</label>
|
||||
{{end}}
|
||||
</fieldset>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<button type="submit">{{if .IsEdit}}Save{{else}}Create{{end}}</button>
|
||||
</form>
|
||||
{{if .IsEdit}}
|
||||
{{if .LastGlobalLocked}}
|
||||
<p class="muted">The only global administrator cannot be deleted.</p>
|
||||
{{else}}
|
||||
<a class="danger" href="/server/users/{{.UserID}}/delete">Delete user</a>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -1,29 +1,16 @@
|
||||
{{/* Users (mockup users.html): everyone who may sign in to the panel, with the
|
||||
reach of each. The Inbound column is there only where the inbound feature
|
||||
is on. */}}
|
||||
{{define "content"}}
|
||||
<h1>Users</h1>
|
||||
{{template "page_head" .Head}}
|
||||
{{if .Flash}}{{template "flash" .Flash}}{{end}}
|
||||
|
||||
{{if .Flash}}<div class="flash">{{.Flash}}</div>{{end}}
|
||||
|
||||
<div class="card">
|
||||
<p><a href="/server/users/new">Create user</a></p>
|
||||
{{if .Users}}
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Username</th><th>Role</th><th>Outbound domains</th>{{if .InboundEnabled}}<th>Inbound domains</th>{{end}}<th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Users}}
|
||||
<tr>
|
||||
<td>{{.User.Username}}</td>
|
||||
<td>{{.User.Role}}</td>
|
||||
<td class="muted">{{if or (eq .User.Role "global") .User.AllDomains}}All{{else}}{{range $i, $n := .DomainNames}}{{if $i}}, {{end}}{{$n}}{{end}}{{end}}</td>
|
||||
{{if $.InboundEnabled}}<td class="muted">{{if or (eq .User.Role "global") .User.AllInboundDomains}}All{{else}}{{range $i, $n := .InboundDomainNames}}{{if $i}}, {{end}}{{$n}}{{end}}{{end}}</td>{{end}}
|
||||
<td class="actions"><a href="/server/users/{{.User.ID}}">Edit</a></td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<p class="muted">No panel users besides the global administrator yet.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
{{template "box_open" .List}}
|
||||
<div class="table-container"><table class="table is-fullwidth is-hoverable">
|
||||
<thead><tr><th>Username</th><th>E-mail</th><th>Role</th><th>Outbound domains</th>{{if .ShowInbound}}<th>Inbound domains</th>{{end}}<th></th></tr></thead>
|
||||
<tbody>{{range .Rows}}
|
||||
<tr><td><strong>{{.Name}}</strong>{{if .You}} {{template "tag" .YouTag}}{{end}}</td><td class="sp-muted">{{wbr_at .Email}}</td><td>{{template "tag" .Role}}</td><td class="sp-muted">{{.Outbound}}</td>{{if $.ShowInbound}}<td class="sp-muted">{{.Inbound}}</td>{{end}}<td class="sp-actions"><a href="{{.Href}}">Edit</a></td></tr>{{end}}
|
||||
</tbody>
|
||||
</table></div>
|
||||
{{template "box_close"}}
|
||||
{{end}}
|
||||
|
||||
@@ -109,7 +109,7 @@ func TestRenderSuppliesTheVersion(t *testing.T) {
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
data := map[string]any{"Title": "t", "User": "admin"}
|
||||
engine.Render(rec, http.StatusOK, "backup", data)
|
||||
engine.Render(rec, http.StatusOK, "mail_queue", data)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
@@ -138,7 +138,7 @@ func TestNavMarksActivePage(t *testing.T) {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
err = engine.Page("help").ExecuteTemplate(&buf, "nav", map[string]any{
|
||||
err = engine.Page("mail_queue").ExecuteTemplate(&buf, "nav", map[string]any{
|
||||
"User": "admin",
|
||||
"Active": "mail_queue",
|
||||
"IsGlobal": true,
|
||||
@@ -166,7 +166,7 @@ func TestNavLeadsWithStatusAndPointsDomainsAtItsOwnPath(t *testing.T) {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := engine.Page("help").ExecuteTemplate(&buf, "nav", map[string]any{
|
||||
if err := engine.Page("mail_queue").ExecuteTemplate(&buf, "nav", map[string]any{
|
||||
"User": "admin",
|
||||
"Active": "status",
|
||||
"IsGlobal": true,
|
||||
@@ -195,7 +195,7 @@ func TestNavShowsInboundWhenEnabled(t *testing.T) {
|
||||
}
|
||||
engine.SetInboundEnabled(true)
|
||||
var buf bytes.Buffer
|
||||
if err := engine.Page("help").ExecuteTemplate(&buf, "nav", map[string]any{
|
||||
if err := engine.Page("mail_queue").ExecuteTemplate(&buf, "nav", map[string]any{
|
||||
"User": "admin",
|
||||
"Active": "status",
|
||||
"IsGlobal": true,
|
||||
@@ -226,7 +226,6 @@ func TestOnlyThePagesMadeOfDataDeclareThemselvesWide(t *testing.T) {
|
||||
}
|
||||
wide := map[string]bool{
|
||||
"mail_queue": true,
|
||||
"system_log": true,
|
||||
"inbound": true, "inbound_domain": true,
|
||||
}
|
||||
for name, page := range engine.Pages() {
|
||||
@@ -271,8 +270,6 @@ func TestSettingsPageDocumentsRateLimits(t *testing.T) {
|
||||
|
||||
func TestDrillDownPagesPlaceBackLinkAboveContent(t *testing.T) {
|
||||
drillDown := map[string]bool{
|
||||
"user_form.html": true,
|
||||
"user_delete.html": true,
|
||||
"inbound_domain.html": true,
|
||||
"inbound_delete.html": true,
|
||||
}
|
||||
@@ -419,24 +416,27 @@ func TestMailQueuePageRendersRetryPolicy(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthenticatedLayoutIncludesHelpDrawer(t *testing.T) {
|
||||
// The help drawer is gone from the old layout as well: a topic is a section of
|
||||
// the Help page, linked from the head of the box it explains, and the Help
|
||||
// entry of the navigation leads there.
|
||||
func TestAuthenticatedLayoutHasNoHelpDrawer(t *testing.T) {
|
||||
engine, err := New("test")
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := engine.Page("help").ExecuteTemplate(&buf, "layout_legacy.html", map[string]any{
|
||||
"Title": "t", "User": "admin", "Active": "help", "IsGlobal": true,
|
||||
if err := engine.Page("mail_queue").ExecuteTemplate(&buf, "layout_legacy.html", map[string]any{
|
||||
"Title": "t", "User": "admin", "Active": "mail_queue", "IsGlobal": true,
|
||||
}); err != nil {
|
||||
t.Fatalf("execute help layout: %v", err)
|
||||
t.Fatalf("execute legacy layout: %v", err)
|
||||
}
|
||||
out := buf.String()
|
||||
for _, want := range []string{
|
||||
`id="help-off"`, `class="help-drawer"`, `help-pane-status`,
|
||||
`for="help-dns"`, `href="/help"`,
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("authenticated layout missing %q", want)
|
||||
if !strings.Contains(out, `href="/help"`) {
|
||||
t.Error("the authenticated layout has no link to the Help page")
|
||||
}
|
||||
for _, gone := range []string{`id="help-off"`, `help-drawer`, `help-pane`, `for="help-`, `help-scrim`} {
|
||||
if strings.Contains(out, gone) {
|
||||
t.Errorf("the authenticated layout still carries the help drawer (%q)", gone)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -479,7 +479,7 @@ func TestNavIncludesHelp(t *testing.T) {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := engine.Page("help").ExecuteTemplate(&buf, "nav", map[string]any{
|
||||
if err := engine.Page("mail_queue").ExecuteTemplate(&buf, "nav", map[string]any{
|
||||
"User": "admin", "Active": "domains", "IsGlobal": true,
|
||||
}); err != nil {
|
||||
t.Fatalf("execute nav: %v", err)
|
||||
@@ -502,7 +502,7 @@ func TestLegacyNavFollowsTheUsersReach(t *testing.T) {
|
||||
data["User"], data["Active"] = "ops", ""
|
||||
data["InboundEnabled"], data["DMARCEnabled"] = true, true
|
||||
var buf bytes.Buffer
|
||||
if err := engine.Page("help").ExecuteTemplate(&buf, "nav", data); err != nil {
|
||||
if err := engine.Page("mail_queue").ExecuteTemplate(&buf, "nav", data); err != nil {
|
||||
t.Fatalf("execute nav: %v", err)
|
||||
}
|
||||
return buf.String()
|
||||
|
||||
+15
-10
@@ -32,9 +32,7 @@ type Engine struct {
|
||||
// from the component kit (kitPages); pages that embed a polling fragment
|
||||
// (architecture.md § Panel HTTP surface) list that fragment's file too, so the
|
||||
// same {{define}} block renders both the initial page and the fragment's own
|
||||
// refresh responses identically. Pages sharing a block of markup (the
|
||||
// encryption fields on the two secret downloads) list that partial the same
|
||||
// way.
|
||||
// refresh responses identically.
|
||||
var pageFiles = map[string][]string{
|
||||
"setup": {"templates/setup.html"},
|
||||
"login": {"templates/login.html"},
|
||||
@@ -42,10 +40,12 @@ var pageFiles = map[string][]string{
|
||||
"health": {"templates/health.html", "templates/health_body.html"},
|
||||
"account": {"templates/account.html"},
|
||||
"settings": {"templates/settings.html"},
|
||||
"system-log": {"templates/system_log.html", "templates/system_log_body.html"},
|
||||
"backup": {"templates/backup.html"},
|
||||
"users": {"templates/users.html"},
|
||||
"user_form": {"templates/user_form.html"},
|
||||
"user_delete": {"templates/user_delete.html"},
|
||||
"backup": {"templates/backup.html", "templates/encrypt_fields.html"},
|
||||
"user": {"templates/user.html"},
|
||||
"user-delete": {"templates/user_delete.html"},
|
||||
"help": {"templates/help.html"},
|
||||
"out-domains": {"templates/out_domains.html"},
|
||||
"out-domain": {"templates/out_domain.html"},
|
||||
"out-domain-settings": {"templates/out_domain_settings.html"},
|
||||
@@ -61,8 +61,6 @@ var pageFiles = map[string][]string{
|
||||
"dmarc-domain": {"templates/dmarc_domain.html"},
|
||||
"dmarc-report": {"templates/dmarc_report.html"},
|
||||
"mail_queue": {"templates/mail_queue.html", "templates/mail_queue_body.html"},
|
||||
"system_log": {"templates/system_log.html", "templates/system_log_body.html"},
|
||||
"help": {"templates/help.html"},
|
||||
"components": {"templates/kit.html"},
|
||||
}
|
||||
|
||||
@@ -97,6 +95,13 @@ var kitPages = map[string]bool{
|
||||
"dmarc": true,
|
||||
"dmarc-domain": true,
|
||||
"dmarc-report": true,
|
||||
|
||||
"system-log": true,
|
||||
"backup": true,
|
||||
"users": true,
|
||||
"user": true,
|
||||
"user-delete": true,
|
||||
"help": true,
|
||||
}
|
||||
|
||||
// fragmentFiles maps a fragment name (also its {{define}} block name) to its
|
||||
@@ -110,7 +115,7 @@ var fragmentFiles = map[string]string{
|
||||
}
|
||||
|
||||
// kitFragments are the fragments rendered with the component kit's partials.
|
||||
var kitFragments = map[string]bool{"health_body": true, "overview_poll": true, "out_log_rows": true}
|
||||
var kitFragments = map[string]bool{"health_body": true, "overview_poll": true, "out_log_rows": true, "system_log_body": true}
|
||||
|
||||
// New parses embedded templates. version is stamped into every page footer.
|
||||
func New(version string) (*Engine, error) {
|
||||
@@ -122,7 +127,7 @@ func New(version string) (*Engine, error) {
|
||||
for name, files := range pageFiles {
|
||||
// A page parses with the layout of its design. The template is named
|
||||
// after the layout file so that its content is the layout's.
|
||||
layout, shared := "layout_legacy.html", []string{"templates/help_drawer.html"}
|
||||
layout, shared := "layout_legacy.html", []string(nil)
|
||||
if kitPages[name] {
|
||||
layout, shared = "layout.html", []string{"templates/components.html"}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user