Files
selfpost/internal/web/handlers/handlers_users.go
T
mixandClaude Opus 5.5 3b86f6c3b6 panel-redesign: inbound is delegated per user, separately from outbound
Stage 1, step 2 (plan § Who sees what).

Store
  - Reach{AllDomains, DomainIDs, AllInbound, InboundDomainIDs} is what a
    domain user is assigned. CreateUser takes it; SetUserReach / ClearUserReach
    replace SetUserDomains / ClearUserDomains and write both directions in one
    transaction. A domain user with nothing on either list is refused
    (ErrEmptyReach). With All set the rows of that direction are not stored,
    so clearing All later brings nothing stale back.
  - ListDomainsForUser honours all_domains; ListInboundDomainsForUser is its
    inbound twin. Both filter in the query.

Authorization
  - auth.Principal carries the whole reach: CanAccessDomain (now also true
    with All), CanAccessInboundDomain, HasOutbound, HasInbound. An id or a
    name on one list opens nothing on the other.
  - Inbound handlers in three strengths: requireInbound (the list, filtered),
    requireInboundDomain (one domain: page, DNS re-check, upstream,
    recipients), requireInboundGlobal (add, delete confirm, delete). All
    answer the same 404.
  - domainIDSet and the DMARC report pages go through the principal instead
    of looking at p.Domains themselves.

Panel (old templates, until stage 2)
  - User form: two lists with All; inbound is offered only where the feature
    is on, and saving the form with it off keeps the user's inbound reach.
    Promotion to global drops the reach.
  - Menu: a group with nothing assigned is absent; / sends an inbound-only
    administrator to Inbound. The inbound pages hide add / delete from a
    domain administrator.

Tests: every route of one inbound domain against five kinds of refused
request (another tenant's id, a missing id, outbound-only reach, an outbound
domain with the same id, one with the same name, no principal) with the 404
bodies compared; add/delete as every non-global user; the filtered list
including a domain added later; thirteen outbound routes against another
tenant's domain and application id; the user form; the reach in the store;
the principal matrix; the menu; the home redirect. Removing the access check
or the query filter makes the tests fail (checked by hand).

go test ./...  green except the two Windows-only failures in internal/domain
and internal/logtail that predate this work.

Roadmap 12/27.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 20:33:37 +03:00

421 lines
14 KiB
Go

package handlers
import (
"errors"
"net/http"
"strconv"
"strings"
"github.com/mixeme/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/web/validate"
"golang.org/x/crypto/bcrypt"
)
type userFormView struct {
FormErr string
FormUsername 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 {
return
}
rows, err := h.store.ListUserRows()
if err != nil {
logf("panel: list users: %v", err)
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)
}
func usersFlash(r *http.Request) string {
switch r.URL.Query().Get("done") {
case "created":
return "User created."
case "updated":
return "User updated."
case "deleted":
return "User deleted."
default:
return ""
}
}
// HandleUserNew creates a panel user (global only).
func (h *Handlers) HandleUserNew(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requireGlobal(w, r); !ok {
return
}
switch r.Method {
case http.MethodGet:
h.renderUserForm(w, r, http.StatusOK, 0, userFormView{FormRole: string(store.RoleDomain)})
case http.MethodPost:
h.submitUserCreate(w, r)
default:
w.Header().Set("Allow", "GET, POST")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
// HandleUserEdit edits or deletes a panel user (global only).
func (h *Handlers) HandleUserEdit(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requireGlobal(w, r); !ok {
return
}
uid, ok := parseUserID(w, r)
if !ok {
return
}
u, err := h.store.GetUser(uid)
if err != nil {
if errors.Is(err, store.ErrUserNotFound) {
http.NotFound(w, r)
return
}
logf("panel: get user %d: %v", uid, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
switch r.Method {
case http.MethodGet:
h.renderUserForm(w, r, http.StatusOK, u.ID, userFormView{
FormUsername: u.Username,
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)})
return
}
h.submitUserUpdate(w, r, u)
default:
w.Header().Set("Allow", "GET, POST")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func (h *Handlers) renderUserForm(w http.ResponseWriter, r *http.Request, status int, userID int64, view 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)
if userID != 0 {
data["Title"] = "SelfPost — edit user"
} else {
data["Title"] = "SelfPost — create user"
}
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 {
logf("panel: user form: list inbound domains: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
data["InboundDomains"] = inboundDomains
}
data["FormPassword"] = view.FormPassword
data["IsEdit"] = userID != 0
data["LastGlobalLocked"] = lastGlobalLocked(h, userID, view.FormRole)
h.view.Render(w, status, "user_form", data)
}
func lastGlobalLocked(h *Handlers, userID int64, formRole string) bool {
if userID == 0 || formRole != string(store.RoleGlobal) {
return false
}
n, err := h.store.CountGlobalUsers()
return err == nil && n <= 1
}
func (h *Handlers) submitUserCreate(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
h.renderUserForm(w, r, http.StatusBadRequest, 0, userFormView{FormErr: "Invalid form submission."})
return
}
username := strings.TrimSpace(r.PostFormValue("username"))
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})
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})
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})
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})
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost)
if err != nil {
logf("panel: create user hash: %v", err)
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 {
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})
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
}
http.Redirect(w, r, "/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)})
return
}
p, ok := h.principal(r)
if !ok {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
username := strings.TrimSpace(r.PostFormValue("username"))
password := r.PostFormValue("password")
role := store.Role(r.PostFormValue("role"))
reach := h.reachFromForm(r, u.Reach())
if username == "" {
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})
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})
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})
return
}
if u.Role == store.RoleGlobal && role == store.RoleDomain {
n, err := h.store.CountGlobalUsers()
if err != nil || n <= 1 {
msg := "Cannot demote the last global administrator."
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})
return
}
}
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})
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})
return
}
hash = string(newHash)
}
if err := h.store.UpdateUser(u.ID, username, hash, u.Email); err != nil {
if errors.Is(err, store.ErrUserExists) {
h.renderUserForm(w, r, http.StatusConflict, u.ID, userFormView{FormErr: "That username is already in use.", FormUsername: username, FormRole: string(role), 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})
return
}
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})
return
}
if role == store.RoleGlobal {
if err := h.store.ClearUserReach(u.ID); err != nil {
logf("panel: clear user reach: %v", err)
}
}
}
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})
return
}
}
http.Redirect(w, r, "/users?done=updated", http.StatusSeeOther)
}
// HandleUserDeleteConfirm shows the cascade warning before a panel user is
// removed — the same pattern as HandleDeleteConfirm for domains, so a single
// mis-click on Delete cannot remove a user (P3, code-review.md).
func (h *Handlers) HandleUserDeleteConfirm(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requireGlobal(w, r); !ok {
return
}
uid, ok := parseUserID(w, r)
if !ok {
return
}
u, err := h.store.GetUser(uid)
if err != nil {
if errors.Is(err, store.ErrUserNotFound) {
http.NotFound(w, r)
return
}
logf("panel: get user %d: %v", uid, err)
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)
}
// HandleUserDelete performs the deletion confirmed on HandleUserDeleteConfirm
// and returns to the user list.
func (h *Handlers) HandleUserDelete(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requireGlobal(w, r); !ok {
return
}
uid, ok := parseUserID(w, r)
if !ok {
return
}
u, err := h.store.GetUser(uid)
if err != nil {
if errors.Is(err, store.ErrUserNotFound) {
http.NotFound(w, r)
return
}
logf("panel: get user %d: %v", uid, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
h.submitUserDelete(w, r, u)
}
func (h *Handlers) submitUserDelete(w http.ResponseWriter, r *http.Request, u store.User) {
p, ok := h.principal(r)
if !ok {
http.Error(w, "internal error", http.StatusInternalServerError)
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)})
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)})
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)})
return
}
http.Redirect(w, r, "/users?done=deleted", http.StatusSeeOther)
}
func parseUserID(w http.ResponseWriter, r *http.Request) (int64, bool) {
id, err := strconv.ParseInt(r.PathValue("uid"), 10, 64)
if err != nil || id <= 0 {
http.NotFound(w, r)
return 0, false
}
return id, true
}
// reachFromForm reads the two assignment lists of the user form. With an All
// box ticked the rows under it are ignored — the form works without scripts,
// so both may arrive — and All then also covers domains added later. Where
// inbound is switched off the form has no inbound list, and what the user
// already had there (current) is kept rather than wiped by an unrelated save.
func (h *Handlers) reachFromForm(r *http.Request, current store.Reach) store.Reach {
reach := store.Reach{
AllDomains: r.PostFormValue("all_domains") != "",
AllInbound: current.AllInbound, InboundDomainIDs: current.InboundDomainIDs,
}
if !reach.AllDomains {
reach.DomainIDs = parseIDs(r, "domain_ids")
}
if h.cfg.InboundEnabled {
reach.AllInbound = r.PostFormValue("all_inbound_domains") != ""
reach.InboundDomainIDs = nil
if !reach.AllInbound {
reach.InboundDomainIDs = parseIDs(r, "inbound_domain_ids")
}
}
return reach
}
func parseIDs(r *http.Request, field string) []int64 {
var ids []int64
seen := make(map[int64]bool)
for _, v := range r.PostForm[field] {
id, err := strconv.ParseInt(v, 10, 64)
if err == nil && id > 0 && !seen[id] {
seen[id] = true
ids = append(ids, id)
}
}
return ids
}
func idSet(ids []int64) map[int64]bool {
m := make(map[int64]bool, len(ids))
for _, id := range ids {
m[id] = true
}
return m
}