package api import ( "context" "encoding/json" "net/http" "net/url" "strings" ) // Role names as stored in the PocketBase users.role select field. Missing/empty // is treated as roleUser. const ( roleUser = "user" roleAdmin = "admin" roleSuperadmin = "superadmin" ) // callerIdentity is who the request token belongs to. type callerIdentity struct { ID string Email string Role string OrgID string // organization record id ("" when the user belongs to no org) } func (c *callerIdentity) isSuperadmin() bool { return c != nil && c.Role == roleSuperadmin } func (c *callerIdentity) isManager() bool { return c != nil && (c.Role == roleAdmin || c.Role == roleSuperadmin) } // identify resolves the caller's id/email/role/org from their PocketBase token. // Role defaults to "user" when the field is empty/absent. func (s *Server) identify(ctx context.Context, token string) (*callerIdentity, int, error) { rec, status, err := s.pbAuthRefresh(ctx, token) if err != nil { return nil, 0, err } if status != http.StatusOK || rec == nil { return nil, status, nil } id := unquote(rec.Record["id"]) email := unquote(rec.Record["email"]) role := unquote(rec.Record["role"]) if role == "" { role = roleUser } org := unquote(rec.Record["organization"]) return &callerIdentity{ID: id, Email: email, Role: role, OrgID: org}, http.StatusOK, nil } func unquote(raw json.RawMessage) string { var s string _ = json.Unmarshal(raw, &s) return s } // GET /api/me — the authenticated caller's identity, including organization. func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) { token := r.Header.Get("Authorization") if token == "" { writeError(w, http.StatusUnauthorized, "missing token") return } who, status, err := s.identify(r.Context(), token) if err != nil { writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()}) return } if status != http.StatusOK || who == nil { writeError(w, http.StatusUnauthorized, "invalid or expired token") return } orgName := "" if who.OrgID != "" { orgName = s.orgName(r.Context(), who.OrgID) } writeJSON(w, http.StatusOK, map[string]any{ "id": who.ID, "email": who.Email, "role": who.Role, "organization": who.OrgID, "organizationName": orgName, }) } // requireManager wraps a handler so only managers (admin or superadmin) may // proceed. The caller's identity is stashed on the request context for reuse. func (s *Server) requireManager(next http.HandlerFunc) http.HandlerFunc { return s.requireRole(next, func(c *callerIdentity) bool { return c.isManager() }, "admin role required") } // requireSuperadmin wraps a handler so only superadmins may proceed. func (s *Server) requireSuperadmin(next http.HandlerFunc) http.HandlerFunc { return s.requireRole(next, func(c *callerIdentity) bool { return c.isSuperadmin() }, "superadmin role required") } // requireSuperadminAuth gates a handler on a valid superadmin token WITHOUT // requiring the service account to be configured. Used by the PocketBase // settings endpoints, whose whole purpose is to configure that service account. func (s *Server) requireSuperadminAuth(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { token := r.Header.Get("Authorization") if token == "" { writeError(w, http.StatusUnauthorized, "missing token") return } who, status, err := s.identify(r.Context(), token) if err != nil { writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()}) return } if status != http.StatusOK || who == nil { writeError(w, http.StatusUnauthorized, "invalid or expired token") return } if !who.isSuperadmin() { writeError(w, http.StatusForbidden, "superadmin role required") return } next(w, r.WithContext(context.WithValue(r.Context(), ctxCaller, who))) } } // requireRole is the shared gate: it needs the service account (all privileged // management flows through it), a valid token, and a caller that satisfies ok. func (s *Server) requireRole(next http.HandlerFunc, ok func(*callerIdentity) bool, denied string) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if !s.admin.configured() { writeError(w, http.StatusServiceUnavailable, "user management not configured on the server") return } token := r.Header.Get("Authorization") if token == "" { writeError(w, http.StatusUnauthorized, "missing token") return } who, status, err := s.identify(r.Context(), token) if err != nil { writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()}) return } if status != http.StatusOK || who == nil { writeError(w, http.StatusUnauthorized, "invalid or expired token") return } if !ok(who) { writeError(w, http.StatusForbidden, denied) return } next(w, r.WithContext(context.WithValue(r.Context(), ctxCaller, who))) } } type ctxKey int const ctxCaller ctxKey = iota func caller(r *http.Request) *callerIdentity { if v, ok := r.Context().Value(ctxCaller).(*callerIdentity); ok { return v } return nil } // userView is the trimmed user shape returned to managers. type userView struct { ID string `json:"id"` Email string `json:"email"` 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) } // getUserRecord fetches a single user's id/email/role/organization 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/users/records/" + url.PathEscape(id) + "?fields=id,email,role,verified,organization" data, status, err := s.admin.do(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/users/records?perPage=500&sort=email&fields=id,email,role,verified,created,organization" if who != nil && !who.isSuperadmin() { // Admin: scope to their own organization. if who.OrgID == "" { // An org-less admin manages nobody. writeJSON(w, http.StatusOK, map[string]any{"users": []userView{}}) return } path += "&filter=" + url.QueryEscape("organization = \""+who.OrgID+"\"") } data, status, err := s.admin.do(r.Context(), http.MethodGet, path, nil) if err != nil { writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()}) return } if status != http.StatusOK { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _, _ = w.Write(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, 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"` 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, "role": role, "verified": true, "emailVisibility": false, } // Only send organization when set; superadmins may deliberately omit it to // create an org-less account. if org != "" { create["organization"] = org } data, status, err := s.admin.do(r.Context(), http.MethodPost, "/api/collections/users/records", create) if err != nil { writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()}) return } if status != http.StatusOK { // Relay PocketBase's validation error (e.g. duplicate email, bad org id). w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _, _ = w.Write(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, 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 demote 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"` 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 { writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()}) 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.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 != nil && 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.admin.do(r.Context(), http.MethodPatch, "/api/collections/users/records/"+url.PathEscape(id), patch) if err != nil { writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()}) return } if status != http.StatusOK { // Relay PocketBase's validation error (e.g. duplicate email, bad org id). w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _, _ = w.Write(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 != nil && who.ID == id { writeError(w, http.StatusBadRequest, "you cannot delete your own account") return } if who != nil && !who.isSuperadmin() { target, err := s.getUserRecord(r.Context(), id) if err != nil { writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()}) 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.admin.do(r.Context(), http.MethodDelete, "/api/collections/users/records/"+url.PathEscape(id), nil) if err != nil { writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()}) return } if status != http.StatusOK && status != http.StatusNoContent { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _, _ = w.Write(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 } }