major fixes
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
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")
|
||||
}
|
||||
Reference in New Issue
Block a user