package httpapi import ( "errors" "net/http" "sort" "github.com/descrybe/descrybe-v2/apps/api/internal/auth" "github.com/descrybe/descrybe-v2/apps/api/internal/billing" "github.com/go-chi/chi/v5" "github.com/google/uuid" ) // memberPermissionsView is the payload for GET/PUT /api/team/{userID}/permissions. type memberPermissionsView struct { UserID uuid.UUID `json:"user_id"` Role string `json:"role"` // IsOwner members are never restricted — the editor renders read-only for them. IsOwner bool `json:"is_owner"` // Denied lists the feature keys turned off for this member (sorted, sparse). Denied []string `json:"denied"` // Restricted is a convenience flag mirroring len(Denied) > 0. Restricted bool `json:"restricted"` } // GET /api/team/permission-catalog — the grantable feature keys grouped by dashboard // section, annotated with what the company plan already allows. func (s *Server) handleGetPermissionCatalog(w http.ResponseWriter, r *http.Request) { if !s.allowCompanyAdminOrPlatform(w, r) { return } if s.Billing == nil { Error(w, http.StatusServiceUnavailable, "billing unavailable") return } cid, _ := CompanyIDFromContext(r.Context()) catalog, err := s.Billing.PermissionCatalogForCompany(r.Context(), cid) if err != nil { Error(w, http.StatusInternalServerError, "failed to load permission catalog") return } w.Header().Set("Cache-Control", "private, max-age=30, must-revalidate") JSON(w, http.StatusOK, catalog) } // GET /api/team/{userID}/permissions func (s *Server) handleGetMemberPermissions(w http.ResponseWriter, r *http.Request) { if !s.allowCompanyAdminOrPlatform(w, r) { return } cid, _ := CompanyIDFromContext(r.Context()) userID, err := uuid.Parse(chi.URLParam(r, "userID")) if err != nil { Error(w, http.StatusBadRequest, "invalid user id") return } view, err := s.memberPermissionsView(r, cid, userID) if err != nil { writeMemberPermissionsErr(w, err) return } JSON(w, http.StatusOK, view) } // PUT /api/team/{userID}/permissions — owner (or platform admin) only. // // Body: {"denied": ["stores.hub", "settings.api_keys"]}. The list replaces the stored // overlay wholesale, so an empty list restores full (plan-limited) access. func (s *Server) handlePutMemberPermissions(w http.ResponseWriter, r *http.Request) { // Owner-only on purpose: company admins can manage the team, but letting a // restricted admin edit permissions would let them lift their own restrictions. if !s.allowCompanyOwnerOrPlatform(w, r) { return } cid, _ := CompanyIDFromContext(r.Context()) userID, err := uuid.Parse(chi.URLParam(r, "userID")) if err != nil { Error(w, http.StatusBadRequest, "invalid user id") return } var body struct { Denied []string `json:"denied"` } if err := DecodeJSON(r, &body); err != nil { Error(w, http.StatusBadRequest, "invalid json") return } isOwner, err := s.Auth.IsCompanyOwner(r.Context(), cid, userID) if err != nil { Error(w, http.StatusInternalServerError, "lookup failed") return } if isOwner { Error(w, http.StatusConflict, "the company owner cannot be restricted") return } incoming := make(map[string]bool, len(body.Denied)) for _, key := range body.Denied { incoming[key] = false } perms, err := billing.SanitizeMemberPermissions(incoming) if err != nil { // Unknown / protected keys are a client mistake — echo which one failed. Error(w, http.StatusBadRequest, err.Error()) return } if err := s.Auth.SetMemberPermissions(r.Context(), cid, userID, perms); err != nil { writeMemberPermissionsErr(w, err) return } view, err := s.memberPermissionsView(r, cid, userID) if err != nil { writeMemberPermissionsErr(w, err) return } JSON(w, http.StatusOK, view) } func (s *Server) memberPermissionsView(r *http.Request, companyID, userID uuid.UUID) (memberPermissionsView, error) { var role, status string if err := s.Pool.QueryRow(r.Context(), ` SELECT role, status FROM memberships WHERE company_id = $1 AND user_id = $2`, companyID, userID).Scan(&role, &status); err != nil { return memberPermissionsView{}, auth.ErrMemberNotFound } perms, err := s.Auth.MemberPermissions(r.Context(), companyID, userID) if err != nil { return memberPermissionsView{}, err } isOwner, err := s.Auth.IsCompanyOwner(r.Context(), companyID, userID) if err != nil { return memberPermissionsView{}, err } denied := deniedKeys(perms) if isOwner { denied = nil } return memberPermissionsView{ UserID: userID, Role: auth.NormalizeMembershipRole(role), IsOwner: isOwner, Denied: denied, Restricted: len(denied) > 0, }, nil } // deniedKeys flattens a stored overlay to the sorted list of turned-off keys, // dropping anything that is no longer grantable (catalog changes, hand edits). func deniedKeys(perms map[string]bool) []string { out := make([]string, 0, len(perms)) for key, allowed := range perms { if !allowed && billing.IsGrantableFeatureKey(key) { out = append(out, key) } } sort.Strings(out) return out } func writeMemberPermissionsErr(w http.ResponseWriter, err error) { if errors.Is(err, auth.ErrMemberNotFound) { Error(w, http.StatusNotFound, "member not found") return } Error(w, http.StatusInternalServerError, "permission update failed") }