package api import ( "context" "encoding/json" "net/http" "net/url" "strings" ) // orgView is the trimmed organization shape returned to clients. type orgView struct { ID string `json:"id"` Name string `json:"name"` Created string `json:"created"` } // orgNameMap returns an id→name map of all organizations via the service // account. On any error it returns an empty (non-nil) map so callers can index // it safely. func (s *Server) orgNameMap(ctx context.Context) map[string]string { out := map[string]string{} if !s.pb.Configured() { return out } data, status, err := s.pb.Raw(ctx, http.MethodGet, "/api/collections/"+colOrgs+"/records?perPage=500&fields=id,name", nil) if err != nil || status != http.StatusOK { return out } var list struct { Items []orgView `json:"items"` } _ = json.Unmarshal(data, &list) for _, o := range list.Items { out[o.ID] = o.Name } return out } // orgName resolves a single organization's name (best effort; "" on miss). func (s *Server) orgName(ctx context.Context, id string) string { if id == "" || !s.pb.Configured() { return "" } data, status, err := s.pb.Raw(ctx, http.MethodGet, "/api/collections/"+colOrgs+"/records/"+url.PathEscape(id)+"?fields=id,name", nil) if err != nil || status != http.StatusOK { return "" } var o orgView _ = json.Unmarshal(data, &o) return o.Name } // GET /api/orgs — list organizations (manager only). Superadmins see all; // admins see only their own organization. func (s *Server) handleListOrgs(w http.ResponseWriter, r *http.Request) { who := caller(r) path := "/api/collections/" + colOrgs + "/records?perPage=500&sort=name&fields=id,name,created" if !who.isSuperadmin() { if who.OrgID == "" { writeJSON(w, http.StatusOK, map[string]any{"organizations": []orgView{}}) return } path += "&filter=" + url.QueryEscape("id = \""+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 []orgView `json:"items"` } _ = json.Unmarshal(data, &list) writeJSON(w, http.StatusOK, map[string]any{"organizations": list.Items}) } // POST /api/orgs — create an organization. Body: {name}. A superadmin may create // any number of organizations and is not attached to them (they manage every // tenant centrally). Any other user may create one only if they don't already // belong to an organization, and they become its admin and first member — the // self-service path for standing up a new tenant. // // This handler carries no requireRole gate (any authenticated caller may reach // it), so it checks the service account itself. func (s *Server) handleCreateOrg(w http.ResponseWriter, r *http.Request) { if !s.pb.Configured() { writeError(w, http.StatusServiceUnavailable, "organizations are not configured on the server") return } who := caller(r) if !who.isSuperadmin() && who.OrgID != "" { writeError(w, http.StatusForbidden, "you already belong to an organization") return } name, ok := decodeOrgName(w, r) if !ok { return } data, status, err := s.pb.Raw(r.Context(), http.MethodPost, "/api/collections/"+colOrgs+"/records", map[string]any{"name": name}) if err != nil { writeUpstreamDown(w, err) return } if status != http.StatusOK { // Relay PocketBase's error (e.g. duplicate name violates the unique index). relay(w, status, data) return } var org orgView _ = json.Unmarshal(data, &org) // A non-superadmin creator becomes the admin of, and first member of, the org // they just made. If that promotion fails, roll the org back so we never leave // a stranded organization that nobody can administer. if !who.isSuperadmin() { data, status, err = s.pb.Raw(r.Context(), http.MethodPatch, "/api/collections/"+s.usersCollection()+"/records/"+url.PathEscape(who.ID), map[string]any{"organization": org.ID, "role": roleAdmin}) if err != nil || status != http.StatusOK { _, _, _ = s.pb.Raw(r.Context(), http.MethodDelete, "/api/collections/"+colOrgs+"/records/"+url.PathEscape(org.ID), nil) if err != nil { writeUpstreamDown(w, err) return } relay(w, status, data) return } } writeJSON(w, http.StatusCreated, map[string]any{"organization": org}) } // PATCH /api/orgs/{id} — rename an organization (manager only). A superadmin may // rename any organization; an admin may rename only their own. Body: {name}. func (s *Server) handleUpdateOrg(w http.ResponseWriter, r *http.Request) { who := caller(r) id := r.PathValue("id") if id == "" { writeError(w, http.StatusBadRequest, "missing organization id") return } // An admin is scoped to their own organization; a superadmin spans all. if !who.isSuperadmin() && (who.OrgID == "" || id != who.OrgID) { writeError(w, http.StatusForbidden, "you can only rename your own organization") return } name, ok := decodeOrgName(w, r) if !ok { return } data, status, err := s.pb.Raw(r.Context(), http.MethodPatch, "/api/collections/"+colOrgs+"/records/"+url.PathEscape(id), map[string]any{"name": name}) if err != nil { writeUpstreamDown(w, err) return } if status != http.StatusOK { relay(w, status, data) return } var org orgView _ = json.Unmarshal(data, &org) writeJSON(w, http.StatusOK, map[string]any{"organization": org}) } // DELETE /api/orgs/{id} — delete an organization (manager only). A superadmin may // delete any organization, but only once it has no members. An admin may delete // only their own organization, and only when they are its sole member: the admin // is detached and demoted back to a plain user, then the org is removed. Either // path refuses to silently orphan other members. func (s *Server) handleDeleteOrg(w http.ResponseWriter, r *http.Request) { who := caller(r) id := r.PathValue("id") if id == "" { writeError(w, http.StatusBadRequest, "missing organization id") return } // Count the members that block deletion. A superadmin is blocked by anyone at // all; an admin deleting their own org is blocked only by *other* members, // since they detach themselves below. filter := "organization = \"" + id + "\"" blocked := "organization still has members; reassign or remove them first" if !who.isSuperadmin() { // An admin is scoped to their own organization. if who.OrgID == "" || id != who.OrgID { writeError(w, http.StatusForbidden, "you can only delete your own organization") return } filter += " && id != \"" + who.ID + "\"" blocked = "organization still has other members; reassign or remove them first" } countPath := "/api/collections/" + s.usersCollection() + "/records?perPage=1&fields=id&filter=" + url.QueryEscape(filter) data, status, err := s.pb.Raw(r.Context(), http.MethodGet, countPath, nil) if err != nil { writeUpstreamDown(w, err) return } if status == http.StatusOK { var page struct { TotalItems int `json:"totalItems"` } _ = json.Unmarshal(data, &page) if page.TotalItems > 0 { writeError(w, http.StatusConflict, blocked) return } } // Detach and demote the admin so the org is empty before it is removed. Done // before the delete because users.organization does not cascade: a failed // delete leaves an empty org a superadmin can clean up, which beats leaving a // member pointing at an organization that no longer exists. if !who.isSuperadmin() { data, status, err = s.pb.Raw(r.Context(), http.MethodPatch, "/api/collections/"+s.usersCollection()+"/records/"+url.PathEscape(who.ID), map[string]any{"organization": "", "role": roleUser}) if err != nil { writeUpstreamDown(w, err) return } if status != http.StatusOK { relay(w, status, data) return } } data, status, err = s.pb.Raw(r.Context(), http.MethodDelete, "/api/collections/"+colOrgs+"/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}) } // decodeOrgName parses and validates a {name} body, writing an error response // and returning ok=false on failure. func decodeOrgName(w http.ResponseWriter, r *http.Request) (string, bool) { var body struct { Name string `json:"name"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { writeError(w, http.StatusBadRequest, "invalid json") return "", false } name := strings.TrimSpace(body.Name) if name == "" { writeError(w, http.StatusBadRequest, "organization name is required") return "", false } if len(name) > 120 { writeError(w, http.StatusBadRequest, "organization name is too long (max 120)") return "", false } return name, true }