major fixes

This commit is contained in:
2026-08-22 18:51:17 +02:00
parent 0ff24b1534
commit 0c154254c3
36 changed files with 2212 additions and 42 deletions
@@ -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")
}