Files
DriverVault/API Server/internal/api/orgs.go
T
tajniak81andClaude Opus 5 cd16d4383f Orgs: let any user create an organization and become its admin
Organization writes were superadmin-only, so standing up a tenant needed
an out-of-band superadmin. Creating one is now self-service, and an admin
manages the org they belong to.

- POST /api/orgs is open to any authenticated user. A creator who isn't a
  superadmin must have no organization yet (a single-valued membership
  relation means a second one would abandon the first), and is promoted to
  the new org's admin and first member in the same request. If that
  promotion fails the org is rolled back, so it is never left stranded
  with nobody able to administer it. Superadmins still create tenants
  without joining them.
- PATCH/DELETE are manager-gated and scope an admin to their own org. An
  admin deletes theirs only as its sole member: they are detached and
  demoted to a plain user before the record goes, so the org is empty when
  it is removed. Other members still block deletion with a 409.
- /api/me now carries organization + organizationName, which the clients
  need to tell "no org yet" from "org you administer".

The panel, Web App (new OrgManager.vue in Settings) and Phone App (new
_OrganizationSection) all mirror the server's gates rather than
re-deciding them. The Phone App cached its role at login and gates the
Users tab on it, so AuthService.adoptRole refreshes that from the profile
instead of making a freshly promoted admin sign in again.

Covered by orgs_test.go, which drives the real handler + middleware chain
against a stand-in PocketBase: promotion, the already-a-member refusal,
superadmin staying unattached, the rollback, own-org scoping, the
detach-and-demote, and the blocking-member 409.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 16:54:02 +02:00

271 lines
8.6 KiB
Go

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
}