Dialog buttons are Cancel then the primary action at the bottom right, and each page has one primary action. List pages no longer repeat their title. Dates have the month written out. Fields in a row line up, hints sit under fields instead of inside labels, and long certificate errors are clamped. Organizations show whether local access is on and name the action; setting a user's password uses a dialog instead of a browser prompt. Server errors for sign-in and ranges are reworded with a next step, and the stray scrollbar inside tables is gone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
643 lines
17 KiB
Go
643 lines
17 KiB
Go
package hammerhead
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.veillax.com/Veillax/reef/model"
|
|
)
|
|
|
|
// ---------- organizations ----------
|
|
|
|
func (s *Server) createOrg(w http.ResponseWriter, r *http.Request, p *principal) {
|
|
var in struct {
|
|
Name string
|
|
AllowLocal bool
|
|
}
|
|
if !decode(w, r, &in) {
|
|
return
|
|
}
|
|
in.Name = strings.TrimSpace(in.Name)
|
|
if in.Name == "" {
|
|
fail(w, 400, errors.New("name is required"))
|
|
return
|
|
}
|
|
s.mu.Lock()
|
|
org, adminRole := s.newOrgLocked(in.Name, in.AllowLocal)
|
|
s.st.Members = append(s.st.Members, &Member{UserID: p.User.ID, OrgID: org.ID, RoleID: adminRole.ID})
|
|
s.save()
|
|
o := *org
|
|
s.mu.Unlock()
|
|
s.audit(o.ID, p.User.Email, "org.create", o.Name)
|
|
writeJSON(w, 200, o)
|
|
}
|
|
|
|
func (s *Server) updateOrg(w http.ResponseWriter, r *http.Request, c *orgCtx) {
|
|
var in struct {
|
|
Name string
|
|
AllowLocal *bool
|
|
}
|
|
if !decode(w, r, &in) {
|
|
return
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
o := s.findOrg(c.Org.ID)
|
|
if n := strings.TrimSpace(in.Name); n != "" {
|
|
o.Name = n
|
|
}
|
|
if in.AllowLocal != nil {
|
|
if !c.User.ServerAdmin {
|
|
fail(w, 403, errors.New("only a server administrator can change this"))
|
|
return
|
|
}
|
|
o.AllowLocal = *in.AllowLocal
|
|
}
|
|
s.bump()
|
|
ok(w)
|
|
}
|
|
|
|
func (s *Server) deleteOrg(w http.ResponseWriter, r *http.Request, c *orgCtx) {
|
|
if !c.User.ServerAdmin {
|
|
fail(w, 403, errors.New("server administrators only"))
|
|
return
|
|
}
|
|
id := c.Org.ID
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.st.Orgs = filter(s.st.Orgs, func(o *Org) bool { return o.ID != id })
|
|
s.st.Roles = filter(s.st.Roles, func(o *Role) bool { return o.OrgID != id })
|
|
s.st.Members = filter(s.st.Members, func(o *Member) bool { return o.OrgID != id })
|
|
s.st.Invites = filter(s.st.Invites, func(o *Invite) bool { return o.OrgID != id })
|
|
s.st.APIKeys = filter(s.st.APIKeys, func(o *APIKey) bool { return o.OrgID != id })
|
|
s.st.Sites = filter(s.st.Sites, func(o *model.Site) bool { return o.OrgID != id })
|
|
s.st.Domains = filter(s.st.Domains, func(o *model.Domain) bool { return o.OrgID != id })
|
|
s.st.Shares = filter(s.st.Shares, func(o *ShareLink) bool { return o.OrgID != id })
|
|
s.st.MailEgress = filter(s.st.MailEgress, func(o *model.MailEgress) bool { return o.OrgID != id })
|
|
s.st.DKIM = filter(s.st.DKIM, func(o *model.DKIMKey) bool { return o.OrgID != id })
|
|
s.st.Channels = filter(s.st.Channels, func(o *AlertChannel) bool { return o.OrgID != id })
|
|
for _, m := range s.st.Mappings {
|
|
if m.OrgID == id {
|
|
delete(s.st.Secrets, m.ID)
|
|
delete(s.st.Totals, m.ID)
|
|
delete(s.status, m.ID)
|
|
}
|
|
}
|
|
s.st.Mappings = filter(s.st.Mappings, func(o *model.Mapping) bool { return o.OrgID != id })
|
|
for _, e := range s.st.Edges {
|
|
e.OrgIDs = removeStr(e.OrgIDs, id)
|
|
}
|
|
s.bump()
|
|
ok(w)
|
|
}
|
|
|
|
func removeStr(in []string, v string) []string {
|
|
out := []string{}
|
|
for _, x := range in {
|
|
if x != v {
|
|
out = append(out, x)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// ---------- roles ----------
|
|
|
|
func (s *Server) listRoles(w http.ResponseWriter, r *http.Request, c *orgCtx) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
writeJSON(w, 200, s.rolesLocked(c.Org.ID))
|
|
}
|
|
|
|
func (s *Server) rolesLocked(orgID string) []Role {
|
|
out := []Role{}
|
|
for _, r := range s.st.Roles {
|
|
if r.OrgID == orgID {
|
|
out = append(out, *r)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (s *Server) saveRole(w http.ResponseWriter, r *http.Request, c *orgCtx) {
|
|
var in Role
|
|
if !decode(w, r, &in) {
|
|
return
|
|
}
|
|
in.Name = strings.TrimSpace(in.Name)
|
|
if in.Name == "" {
|
|
fail(w, 400, errors.New("name is required"))
|
|
return
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
for _, o := range s.st.Roles {
|
|
if o.OrgID == c.Org.ID && strings.EqualFold(o.Name, in.Name) && o.ID != r.PathValue("role") {
|
|
fail(w, 400, errors.New("a role with that name exists"))
|
|
return
|
|
}
|
|
}
|
|
if id := r.PathValue("role"); id != "" {
|
|
role := s.findRole(id)
|
|
if role == nil || role.OrgID != c.Org.ID {
|
|
fail(w, 404, errors.New("no such role"))
|
|
return
|
|
}
|
|
if role.Builtin {
|
|
fail(w, 400, errors.New("built-in roles can't be edited"))
|
|
return
|
|
}
|
|
role.Name, role.Admin = in.Name, in.Admin
|
|
} else {
|
|
s.st.Roles = append(s.st.Roles, &Role{ID: newID(), OrgID: c.Org.ID, Name: in.Name, Admin: in.Admin})
|
|
}
|
|
s.save()
|
|
s.audit(c.Org.ID, c.Actor, "role.save", in.Name)
|
|
ok(w)
|
|
}
|
|
|
|
func (s *Server) deleteRole(w http.ResponseWriter, r *http.Request, c *orgCtx) {
|
|
id := r.PathValue("role")
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
role := s.findRole(id)
|
|
if role == nil || role.OrgID != c.Org.ID {
|
|
fail(w, 404, errors.New("no such role"))
|
|
return
|
|
}
|
|
if role.Builtin {
|
|
fail(w, 400, errors.New("built-in roles can't be deleted"))
|
|
return
|
|
}
|
|
for _, m := range s.st.Members {
|
|
if m.RoleID == id {
|
|
fail(w, 400, errors.New("that role still has members; move them first"))
|
|
return
|
|
}
|
|
}
|
|
s.st.Roles = filter(s.st.Roles, func(o *Role) bool { return o.ID != id })
|
|
for _, m := range s.st.Mappings {
|
|
m.Auth.AllowedRoles = removeStr(m.Auth.AllowedRoles, id)
|
|
}
|
|
s.revokeOrgVisitorsLocked(c.Org.ID)
|
|
s.bump()
|
|
ok(w)
|
|
}
|
|
|
|
// ---------- members ----------
|
|
|
|
func (s *Server) listMembers(w http.ResponseWriter, r *http.Request, c *orgCtx) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
out := []map[string]any{}
|
|
for _, m := range s.st.Members {
|
|
if m.OrgID != c.Org.ID {
|
|
continue
|
|
}
|
|
u := s.findUser(m.UserID)
|
|
if u == nil {
|
|
continue
|
|
}
|
|
out = append(out, map[string]any{"userId": u.ID, "email": u.Email, "name": u.Name, "roleId": m.RoleID,
|
|
"totp": u.TOTPEnabled, "sso": u.IdP != ""})
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i]["email"].(string) < out[j]["email"].(string) })
|
|
writeJSON(w, 200, out)
|
|
}
|
|
|
|
// adminCountLocked counts members with an admin role, excluding skip.
|
|
func (s *Server) adminCountLocked(orgID, skip string) int {
|
|
n := 0
|
|
for _, m := range s.st.Members {
|
|
if m.OrgID == orgID && m.UserID != skip {
|
|
if role := s.findRole(m.RoleID); role != nil && role.Admin {
|
|
n++
|
|
}
|
|
}
|
|
}
|
|
return n
|
|
}
|
|
|
|
func (s *Server) setMemberRole(w http.ResponseWriter, r *http.Request, c *orgCtx) {
|
|
var in struct{ RoleID string }
|
|
if !decode(w, r, &in) {
|
|
return
|
|
}
|
|
uid := r.PathValue("user")
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
m := s.member(uid, c.Org.ID)
|
|
role := s.findRole(in.RoleID)
|
|
if m == nil || role == nil || role.OrgID != c.Org.ID {
|
|
fail(w, 404, errors.New("no such member or role"))
|
|
return
|
|
}
|
|
if !role.Admin && s.adminCountLocked(c.Org.ID, uid) == 0 {
|
|
fail(w, 400, errors.New("an organization needs at least one admin"))
|
|
return
|
|
}
|
|
m.RoleID = in.RoleID
|
|
s.revokeOrgVisitorsLocked(c.Org.ID) // what they may open just changed
|
|
s.bump()
|
|
s.audit(c.Org.ID, c.Actor, "member.role", uid+" -> "+role.Name)
|
|
ok(w)
|
|
}
|
|
|
|
func (s *Server) removeMember(w http.ResponseWriter, r *http.Request, c *orgCtx) {
|
|
uid := r.PathValue("user")
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.member(uid, c.Org.ID) == nil {
|
|
fail(w, 404, errors.New("no such member"))
|
|
return
|
|
}
|
|
if s.adminCountLocked(c.Org.ID, uid) == 0 {
|
|
fail(w, 400, errors.New("an organization needs at least one admin"))
|
|
return
|
|
}
|
|
s.st.Members = filter(s.st.Members, func(m *Member) bool { return !(m.UserID == uid && m.OrgID == c.Org.ID) })
|
|
for _, m := range s.st.Mappings {
|
|
if m.OrgID == c.Org.ID {
|
|
m.Auth.AllowedUsers = removeStr(m.Auth.AllowedUsers, uid)
|
|
}
|
|
}
|
|
s.revokeOrgVisitorsLocked(c.Org.ID) // the removed member's open sessions end now
|
|
s.bump()
|
|
s.audit(c.Org.ID, c.Actor, "member.remove", uid)
|
|
ok(w)
|
|
}
|
|
|
|
// ---------- invites ----------
|
|
|
|
func (s *Server) listInvites(w http.ResponseWriter, r *http.Request, c *orgCtx) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
out := []Invite{}
|
|
for _, i := range s.st.Invites {
|
|
if i.OrgID == c.Org.ID && time.Now().Before(i.Expires) {
|
|
cp := *i
|
|
cp.TokenHash = ""
|
|
out = append(out, cp)
|
|
}
|
|
}
|
|
writeJSON(w, 200, out)
|
|
}
|
|
|
|
func (s *Server) createInvite(w http.ResponseWriter, r *http.Request, c *orgCtx) {
|
|
var in struct{ Email, RoleID string }
|
|
if !decode(w, r, &in) {
|
|
return
|
|
}
|
|
in.Email = strings.ToLower(strings.TrimSpace(in.Email))
|
|
if !strings.Contains(in.Email, "@") {
|
|
fail(w, 400, errors.New("enter a valid email address"))
|
|
return
|
|
}
|
|
tok := randHex(20)
|
|
s.mu.Lock()
|
|
role := s.findRole(in.RoleID)
|
|
if role == nil || role.OrgID != c.Org.ID {
|
|
s.mu.Unlock()
|
|
fail(w, 400, errors.New("choose a role"))
|
|
return
|
|
}
|
|
s.st.Invites = append(s.st.Invites, &Invite{ID: newID(), OrgID: c.Org.ID, Email: in.Email, RoleID: role.ID,
|
|
TokenHash: sha(tok), Expires: time.Now().Add(7 * 24 * time.Hour), Created: time.Now()})
|
|
s.save()
|
|
s.mu.Unlock()
|
|
link := s.baseURL(r) + "/#invite=" + tok
|
|
emailed := false
|
|
if s.mailConfigured() {
|
|
body := fmt.Sprintf("%s invited you to join %q on Hammerhead.\n\nAccept: %s\n\nThe link works for 7 days.", c.Actor, c.Org.Name, link)
|
|
emailed = s.Mailer(in.Email, "You're invited to "+c.Org.Name, body) == nil
|
|
}
|
|
s.audit(c.Org.ID, c.Actor, "invite.create", in.Email)
|
|
writeJSON(w, 200, map[string]any{"link": link, "emailed": emailed})
|
|
}
|
|
|
|
func (s *Server) deleteInvite(w http.ResponseWriter, r *http.Request, c *orgCtx) {
|
|
id := r.PathValue("id")
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.st.Invites = filter(s.st.Invites, func(i *Invite) bool { return !(i.ID == id && i.OrgID == c.Org.ID) })
|
|
s.save()
|
|
ok(w)
|
|
}
|
|
|
|
func (s *Server) inviteInfo(w http.ResponseWriter, r *http.Request) {
|
|
h := sha(r.PathValue("token"))
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
for _, i := range s.st.Invites {
|
|
if i.TokenHash == h && time.Now().Before(i.Expires) {
|
|
org := s.findOrg(i.OrgID)
|
|
writeJSON(w, 200, map[string]any{"email": i.Email, "org": org.Name, "existing": s.userByEmail(i.Email) != nil})
|
|
return
|
|
}
|
|
}
|
|
fail(w, 404, errors.New("this invitation is invalid or has expired"))
|
|
}
|
|
|
|
func (s *Server) acceptInvite(w http.ResponseWriter, r *http.Request) {
|
|
var in struct{ Token, Name, Password string }
|
|
if !decode(w, r, &in) {
|
|
return
|
|
}
|
|
if !s.allow("invite|"+clientIP(r), 20, 10*time.Minute) {
|
|
fail(w, 429, errors.New("Too many attempts. Wait a few minutes and try again."))
|
|
return
|
|
}
|
|
h := sha(in.Token)
|
|
p := s.principalFrom(r)
|
|
var hash string
|
|
s.mu.Lock()
|
|
var inv *Invite
|
|
for _, i := range s.st.Invites {
|
|
if i.TokenHash == h && time.Now().Before(i.Expires) {
|
|
inv = i
|
|
}
|
|
}
|
|
existing := (*User)(nil)
|
|
if inv != nil {
|
|
existing = s.userByEmail(inv.Email)
|
|
}
|
|
s.mu.Unlock()
|
|
if inv == nil {
|
|
fail(w, 404, errors.New("this invitation is invalid or has expired"))
|
|
return
|
|
}
|
|
if existing == nil {
|
|
var err error
|
|
if hash, err = hashPassword(in.Password); err != nil {
|
|
fail(w, 400, err)
|
|
return
|
|
}
|
|
} else if p == nil || p.User.ID != existing.ID {
|
|
fail(w, 400, errors.New("an account with this email already exists: sign in first, then open the link again"))
|
|
return
|
|
}
|
|
s.mu.Lock()
|
|
uid := ""
|
|
if existing != nil {
|
|
uid = existing.ID
|
|
} else {
|
|
name := strings.TrimSpace(in.Name)
|
|
if name == "" {
|
|
name = inv.Email
|
|
}
|
|
u := &User{ID: newID(), Email: inv.Email, Name: name, PassHash: hash, Created: time.Now()}
|
|
s.st.Users = append(s.st.Users, u)
|
|
uid = u.ID
|
|
}
|
|
if s.member(uid, inv.OrgID) == nil {
|
|
s.st.Members = append(s.st.Members, &Member{UserID: uid, OrgID: inv.OrgID, RoleID: inv.RoleID})
|
|
}
|
|
s.st.Invites = filter(s.st.Invites, func(i *Invite) bool { return i.ID != inv.ID })
|
|
s.save()
|
|
s.mu.Unlock()
|
|
if existing == nil {
|
|
s.startSession(w, r, uid)
|
|
}
|
|
s.audit(inv.OrgID, inv.Email, "invite.accept", "")
|
|
ok(w)
|
|
}
|
|
|
|
// ---------- API keys ----------
|
|
|
|
func (s *Server) listKeys(w http.ResponseWriter, r *http.Request, c *orgCtx) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
out := []APIKey{}
|
|
for _, k := range s.st.APIKeys {
|
|
if k.OrgID == c.Org.ID {
|
|
cp := *k
|
|
cp.Hash = ""
|
|
out = append(out, cp)
|
|
}
|
|
}
|
|
writeJSON(w, 200, out)
|
|
}
|
|
|
|
func (s *Server) createKey(w http.ResponseWriter, r *http.Request, c *orgCtx) {
|
|
var in struct{ Name string }
|
|
if !decode(w, r, &in) {
|
|
return
|
|
}
|
|
in.Name = strings.TrimSpace(in.Name)
|
|
if in.Name == "" {
|
|
fail(w, 400, errors.New("name is required"))
|
|
return
|
|
}
|
|
id, secret := randHex(4), randHex(24)
|
|
s.mu.Lock()
|
|
s.st.APIKeys = append(s.st.APIKeys, &APIKey{ID: id, OrgID: c.Org.ID, Name: in.Name, Hash: sha(secret), Created: time.Now()})
|
|
s.save()
|
|
s.mu.Unlock()
|
|
s.audit(c.Org.ID, c.Actor, "apikey.create", in.Name)
|
|
writeJSON(w, 200, map[string]string{"key": "hhk_" + id + "_" + secret})
|
|
}
|
|
|
|
func (s *Server) deleteKey(w http.ResponseWriter, r *http.Request, c *orgCtx) {
|
|
id := r.PathValue("id")
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.st.APIKeys = filter(s.st.APIKeys, func(k *APIKey) bool { return !(k.ID == id && k.OrgID == c.Org.ID) })
|
|
s.save()
|
|
s.audit(c.Org.ID, c.Actor, "apikey.delete", id)
|
|
ok(w)
|
|
}
|
|
|
|
// ---------- logs & stats ----------
|
|
|
|
func (s *Server) orgLogs(w http.ResponseWriter, r *http.Request, c *orgCtx) {
|
|
q := r.URL.Query()
|
|
limit, _ := strconv.Atoi(q.Get("limit"))
|
|
before, _ := strconv.ParseInt(q.Get("before"), 10, 64)
|
|
if q.Get("kind") == "audit" {
|
|
writeJSON(w, 200, s.logs.queryAudit(c.Org.ID, limit, before))
|
|
return
|
|
}
|
|
class, _ := strconv.Atoi(q.Get("status"))
|
|
writeJSON(w, 200, s.logs.queryAccess(accessFilter{Org: c.Org.ID, Mapping: q.Get("mapping"), Q: strings.TrimSpace(q.Get("q")),
|
|
Result: q.Get("result"), StatusClass: class, Before: before, Limit: limit}))
|
|
}
|
|
|
|
// orgTraffic returns bytes per hour for each of the org's ports, oldest first.
|
|
func (s *Server) orgTraffic(w http.ResponseWriter, r *http.Request, c *orgCtx) {
|
|
hours, _ := strconv.Atoi(r.URL.Query().Get("hours"))
|
|
s.mu.Lock()
|
|
var ids []string
|
|
for _, m := range s.st.Mappings {
|
|
if m.OrgID == c.Org.ID {
|
|
ids = append(ids, m.ID)
|
|
}
|
|
}
|
|
s.mu.Unlock()
|
|
writeJSON(w, 200, s.db.trafficHistory(ids, hours))
|
|
}
|
|
|
|
func (s *Server) orgStats(w http.ResponseWriter, r *http.Request, c *orgCtx) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
out := map[string]Total{}
|
|
for _, m := range s.st.Mappings {
|
|
if m.OrgID == c.Org.ID {
|
|
if t := s.st.Totals[m.ID]; t != nil {
|
|
out[m.ID] = *t
|
|
}
|
|
}
|
|
}
|
|
writeJSON(w, 200, out)
|
|
}
|
|
|
|
// ---------- server admin: users ----------
|
|
|
|
func (s *Server) adminUsers(w http.ResponseWriter, r *http.Request, p *principal) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
out := []map[string]any{}
|
|
for _, u := range s.st.Users {
|
|
orgs := []string{}
|
|
for _, m := range s.st.Members {
|
|
if m.UserID == u.ID {
|
|
if o := s.findOrg(m.OrgID); o != nil {
|
|
orgs = append(orgs, o.Name)
|
|
}
|
|
}
|
|
}
|
|
out = append(out, map[string]any{"id": u.ID, "email": u.Email, "name": u.Name, "serverAdmin": u.ServerAdmin,
|
|
"totp": u.TOTPEnabled, "sso": u.IdP != "", "orgs": orgs})
|
|
}
|
|
writeJSON(w, 200, out)
|
|
}
|
|
|
|
func (s *Server) adminCreateUser(w http.ResponseWriter, r *http.Request, p *principal) {
|
|
var in struct {
|
|
Email, Name, Password string
|
|
ServerAdmin bool
|
|
}
|
|
if !decode(w, r, &in) {
|
|
return
|
|
}
|
|
hash, err := hashPassword(in.Password)
|
|
if err != nil {
|
|
fail(w, 400, err)
|
|
return
|
|
}
|
|
in.Email = strings.ToLower(strings.TrimSpace(in.Email))
|
|
if !strings.Contains(in.Email, "@") {
|
|
fail(w, 400, errors.New("enter a valid email address"))
|
|
return
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.userByEmail(in.Email) != nil {
|
|
fail(w, 400, errors.New("that email already has an account"))
|
|
return
|
|
}
|
|
name := strings.TrimSpace(in.Name)
|
|
if name == "" {
|
|
name = in.Email
|
|
}
|
|
s.st.Users = append(s.st.Users, &User{ID: newID(), Email: in.Email, Name: name, PassHash: hash, ServerAdmin: in.ServerAdmin, Created: time.Now()})
|
|
s.save()
|
|
s.audit("", p.User.Email, "user.create", in.Email)
|
|
ok(w)
|
|
}
|
|
|
|
func (s *Server) adminUpdateUser(w http.ResponseWriter, r *http.Request, p *principal) {
|
|
var in struct {
|
|
ServerAdmin *bool
|
|
ResetTOTP bool
|
|
Password string
|
|
SignOut bool
|
|
}
|
|
if !decode(w, r, &in) {
|
|
return
|
|
}
|
|
var hash string
|
|
if in.Password != "" {
|
|
var err error
|
|
if hash, err = hashPassword(in.Password); err != nil {
|
|
fail(w, 400, err)
|
|
return
|
|
}
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
u := s.findUser(r.PathValue("id"))
|
|
if u == nil {
|
|
fail(w, 404, errors.New("no such user"))
|
|
return
|
|
}
|
|
if in.ServerAdmin != nil {
|
|
if !*in.ServerAdmin && u.ServerAdmin {
|
|
n := 0
|
|
for _, o := range s.st.Users {
|
|
if o.ServerAdmin {
|
|
n++
|
|
}
|
|
}
|
|
if n <= 1 {
|
|
fail(w, 400, errors.New("there must be at least one server administrator"))
|
|
return
|
|
}
|
|
}
|
|
u.ServerAdmin = *in.ServerAdmin
|
|
}
|
|
if in.ResetTOTP {
|
|
u.TOTPEnabled, u.TOTPSecret, u.BackupCodes = false, "", nil
|
|
}
|
|
if hash != "" || in.SignOut {
|
|
if hash != "" {
|
|
u.PassHash = hash
|
|
}
|
|
for k, sess := range s.st.Sessions {
|
|
if sess.UserID == u.ID {
|
|
delete(s.st.Sessions, k)
|
|
}
|
|
}
|
|
}
|
|
s.save()
|
|
s.audit("", p.User.Email, "user.update", u.Email)
|
|
ok(w)
|
|
}
|
|
|
|
func (s *Server) adminDeleteUser(w http.ResponseWriter, r *http.Request, p *principal) {
|
|
id := r.PathValue("id")
|
|
if id == p.User.ID {
|
|
fail(w, 400, errors.New("you can't delete yourself"))
|
|
return
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
u := s.findUser(id)
|
|
if u == nil {
|
|
fail(w, 404, errors.New("no such user"))
|
|
return
|
|
}
|
|
for _, mem := range s.st.Members {
|
|
if mem.UserID == id {
|
|
s.revokeOrgVisitorsLocked(mem.OrgID) // their open sessions on protected resources end now
|
|
}
|
|
}
|
|
s.st.Users = filter(s.st.Users, func(o *User) bool { return o.ID != id })
|
|
s.st.Members = filter(s.st.Members, func(o *Member) bool { return o.UserID != id })
|
|
for k, sess := range s.st.Sessions {
|
|
if sess.UserID == id {
|
|
delete(s.st.Sessions, k)
|
|
}
|
|
}
|
|
for _, m := range s.st.Mappings {
|
|
m.Auth.AllowedUsers = removeStr(m.Auth.AllowedUsers, id)
|
|
}
|
|
s.bump()
|
|
s.audit("", p.User.Email, "user.delete", u.Email)
|
|
ok(w)
|
|
}
|