package api import ( "context" "encoding/json" "net/http" "net/url" "strings" ) // userView is the trimmed user shape returned to managers. type userView struct { ID string `json:"id"` Email string `json:"email"` Name string `json:"name"` Role string `json:"role"` Verified bool `json:"verified"` Created string `json:"created"` Organization string `json:"organization"` // org record id ("" = none) OrganizationName string `json:"organizationName"` // resolved name ("" = none) } // userFields is the field set fetched for a userView. const userFields = "id,email,name,role,verified,created,organization" // getUserRecord fetches a single user via the service account. Returns nil (not // an error) when the user does not exist. func (s *Server) getUserRecord(ctx context.Context, id string) (*userView, error) { path := "/api/collections/" + s.usersCollection() + "/records/" + url.PathEscape(id) + "?fields=" + userFields data, status, err := s.pb.Raw(ctx, http.MethodGet, path, nil) if err != nil { return nil, err } if status != http.StatusOK { return nil, nil } var v userView if err := json.Unmarshal(data, &v); err != nil { return nil, err } if v.Role == "" { v.Role = roleUser } return &v, nil } // GET /api/users — list users (manager only). Superadmins see everyone; admins // see only their own organization's members. func (s *Server) handleListUsers(w http.ResponseWriter, r *http.Request) { who := caller(r) path := "/api/collections/" + s.usersCollection() + "/records?perPage=500&sort=email&fields=" + userFields if !who.isSuperadmin() { // Admin: scope to their own organization. An org-less admin manages nobody. if who.OrgID == "" { writeJSON(w, http.StatusOK, map[string]any{"users": []userView{}}) return } path += "&filter=" + url.QueryEscape("organization = \""+who.OrgID+"\"") } data, status, err := s.pb.Raw(r.Context(), http.MethodGet, path, nil) if err != nil { writeUpstreamDown(w, err) return } if status != http.StatusOK { relay(w, status, data) return } var list struct { Items []userView `json:"items"` } _ = json.Unmarshal(data, &list) names := s.orgNameMap(r.Context()) for i := range list.Items { if list.Items[i].Role == "" { list.Items[i].Role = roleUser } list.Items[i].OrganizationName = names[list.Items[i].Organization] } writeJSON(w, http.StatusOK, map[string]any{"users": list.Items}) } // POST /api/users — create a user (manager only). Body: {email, password, name?, // role?, organization?}. Admins may only create within their own org and may not // mint superadmins; superadmins may target any org (or none) and any role. func (s *Server) handleCreateUser(w http.ResponseWriter, r *http.Request) { who := caller(r) var body struct { Email string `json:"email"` Password string `json:"password"` Name string `json:"name"` Role string `json:"role"` Organization string `json:"organization"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { writeError(w, http.StatusBadRequest, "invalid json") return } body.Email = strings.TrimSpace(strings.ToLower(body.Email)) if body.Email == "" || !strings.Contains(body.Email, "@") { writeError(w, http.StatusBadRequest, "a valid email is required") return } if len(body.Password) < 8 { writeError(w, http.StatusBadRequest, "password must be at least 8 characters") return } role, ok := normalizeRole(body.Role) if !ok { writeError(w, http.StatusBadRequest, "role must be 'user', 'admin', or 'superadmin'") return } org := strings.TrimSpace(body.Organization) if !who.isSuperadmin() { // Admin: no superadmins, and members are forced into the admin's own org. if role == roleSuperadmin { writeError(w, http.StatusForbidden, "only a superadmin can create superadmins") return } if who.OrgID == "" { writeError(w, http.StatusForbidden, "your account is not attached to an organization") return } org = who.OrgID } create := map[string]any{ "email": body.Email, "password": body.Password, "passwordConfirm": body.Password, "name": strings.TrimSpace(body.Name), "role": role, "verified": true, "emailVisibility": false, } // Only send organization when set; a superadmin may deliberately omit it to // create an org-less account. if org != "" { create["organization"] = org } data, status, err := s.pb.Raw(r.Context(), http.MethodPost, "/api/collections/"+s.usersCollection()+"/records", create) if err != nil { writeUpstreamDown(w, err) return } if status != http.StatusOK { // Relay PocketBase's validation error (e.g. duplicate email, bad org id). relay(w, status, data) return } var rec userView _ = json.Unmarshal(data, &rec) if rec.Role == "" { rec.Role = role } rec.OrganizationName = s.orgName(r.Context(), rec.Organization) writeJSON(w, http.StatusCreated, map[string]any{"user": rec}) } // PATCH /api/users/{id} — edit a user (manager only). Any subset of // {email, name, role, password, verified, organization} may be supplied. Admins // are scoped to their own org and cannot touch superadmins or grant the // superadmin role; nobody can change their own role (avoids self-lockout). func (s *Server) handleUpdateUser(w http.ResponseWriter, r *http.Request) { who := caller(r) id := r.PathValue("id") if id == "" { writeError(w, http.StatusBadRequest, "missing user id") return } var body struct { Email string `json:"email"` Name *string `json:"name"` Role string `json:"role"` Password string `json:"password"` Verified *bool `json:"verified"` Organization *string `json:"organization"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { writeError(w, http.StatusBadRequest, "invalid json") return } // Resolve the target so we can enforce org/role scoping. target, err := s.getUserRecord(r.Context(), id) if err != nil { writeUpstreamDown(w, err) return } if target == nil { writeError(w, http.StatusNotFound, "user not found") return } if !who.isSuperadmin() { // Admin scoping: target must be inside the admin's org and not a superadmin. if who.OrgID == "" || target.Organization != who.OrgID { writeError(w, http.StatusForbidden, "user is outside your organization") return } if target.Role == roleSuperadmin { writeError(w, http.StatusForbidden, "you cannot edit a superadmin") return } } patch := map[string]any{} if email := strings.TrimSpace(strings.ToLower(body.Email)); email != "" { if !strings.Contains(email, "@") { writeError(w, http.StatusBadRequest, "a valid email is required") return } patch["email"] = email } if body.Name != nil { patch["name"] = strings.TrimSpace(*body.Name) } if body.Role != "" { role, ok := normalizeRole(body.Role) if !ok { writeError(w, http.StatusBadRequest, "role must be 'user', 'admin', or 'superadmin'") return } if !who.isSuperadmin() && role == roleSuperadmin { writeError(w, http.StatusForbidden, "only a superadmin can grant the superadmin role") return } if who.ID == id && role != who.Role { writeError(w, http.StatusBadRequest, "you cannot change your own role") return } patch["role"] = role } if body.Password != "" { if len(body.Password) < 8 { writeError(w, http.StatusBadRequest, "password must be at least 8 characters") return } patch["password"] = body.Password patch["passwordConfirm"] = body.Password } if body.Verified != nil { patch["verified"] = *body.Verified } // Organization moves are superadmin-only; admins cannot reassign membership. if body.Organization != nil { if !who.isSuperadmin() { if *body.Organization != who.OrgID { writeError(w, http.StatusForbidden, "you cannot move users to another organization") return } // no-op for admins staying in their own org } else { patch["organization"] = *body.Organization // "" clears membership } } if len(patch) == 0 { writeError(w, http.StatusBadRequest, "no changes provided") return } data, status, err := s.pb.Raw(r.Context(), http.MethodPatch, "/api/collections/"+s.usersCollection()+"/records/"+url.PathEscape(id), patch) if err != nil { writeUpstreamDown(w, err) return } if status != http.StatusOK { relay(w, status, data) return } var rec userView _ = json.Unmarshal(data, &rec) if rec.Role == "" { rec.Role = roleUser } rec.OrganizationName = s.orgName(r.Context(), rec.Organization) writeJSON(w, http.StatusOK, map[string]any{"user": rec}) } // DELETE /api/users/{id} — delete a user (manager only). Admins may delete only // non-superadmin members of their own org; nobody can delete their own account. func (s *Server) handleDeleteUser(w http.ResponseWriter, r *http.Request) { who := caller(r) id := r.PathValue("id") if id == "" { writeError(w, http.StatusBadRequest, "missing user id") return } if who.ID == id { writeError(w, http.StatusBadRequest, "you cannot delete your own account") return } if !who.isSuperadmin() { target, err := s.getUserRecord(r.Context(), id) if err != nil { writeUpstreamDown(w, err) return } if target == nil { writeError(w, http.StatusNotFound, "user not found") return } if who.OrgID == "" || target.Organization != who.OrgID { writeError(w, http.StatusForbidden, "user is outside your organization") return } if target.Role == roleSuperadmin { writeError(w, http.StatusForbidden, "you cannot delete a superadmin") return } } data, status, err := s.pb.Raw(r.Context(), http.MethodDelete, "/api/collections/"+s.usersCollection()+"/records/"+url.PathEscape(id), nil) if err != nil { writeUpstreamDown(w, err) return } if status != http.StatusOK && status != http.StatusNoContent { relay(w, status, data) return } writeJSON(w, http.StatusOK, map[string]any{"ok": true}) } // normalizeRole validates a client-supplied role. Returns the canonical value // and whether it was recognised. func normalizeRole(role string) (string, bool) { switch strings.TrimSpace(strings.ToLower(role)) { case "", roleUser: return roleUser, true case roleAdmin: return roleAdmin, true case roleSuperadmin: return roleSuperadmin, true default: return "", false } }