major fixes
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// ErrMemberNotFound is returned when the target user has no membership in the company.
|
||||
var ErrMemberNotFound = errors.New("member not found")
|
||||
|
||||
// MemberPermissions returns the stored per-member access overlay (a sparse map of
|
||||
// dashboard feature key -> false). Empty means unrestricted.
|
||||
//
|
||||
// This is the raw stored value for the owner-facing editor; capability resolution for
|
||||
// a live request goes through billing.CapabilitiesForMember, which also ignores the
|
||||
// overlay for the company owner and intersects it with the plan.
|
||||
func (s *Service) MemberPermissions(ctx context.Context, companyID, userID uuid.UUID) (map[string]bool, error) {
|
||||
var raw []byte
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
SELECT COALESCE(permissions, '{}'::jsonb)
|
||||
FROM memberships
|
||||
WHERE company_id = $1 AND user_id = $2`, companyID, userID).Scan(&raw)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrMemberNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[string]bool{}
|
||||
if len(raw) > 0 {
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return map[string]bool{}, nil
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SetMemberPermissions replaces the overlay for one member. Callers must sanitize the
|
||||
// map first (billing.SanitizeMemberPermissions) — this layer only persists it.
|
||||
func (s *Service) SetMemberPermissions(ctx context.Context, companyID, userID uuid.UUID, perms map[string]bool) error {
|
||||
if perms == nil {
|
||||
perms = map[string]bool{}
|
||||
}
|
||||
encoded, err := json.Marshal(perms)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tag, err := s.Pool.Exec(ctx, `
|
||||
UPDATE memberships
|
||||
SET permissions = $3::jsonb, updated_at = now()
|
||||
WHERE company_id = $1 AND user_id = $2`, companyID, userID, encoded)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrMemberNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MemberPermissionsByCompany returns every restricted member's overlay for one company,
|
||||
// keyed by user id. Members with an empty overlay are omitted, so the team list can show
|
||||
// a "restricted" badge without an N+1 query.
|
||||
func (s *Service) MemberPermissionsByCompany(ctx context.Context, companyID uuid.UUID) (map[uuid.UUID]map[string]bool, error) {
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT user_id, permissions
|
||||
FROM memberships
|
||||
WHERE company_id = $1 AND permissions <> '{}'::jsonb`, companyID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := map[uuid.UUID]map[string]bool{}
|
||||
for rows.Next() {
|
||||
var userID uuid.UUID
|
||||
var raw []byte
|
||||
if err := rows.Scan(&userID, &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
perms := map[string]bool{}
|
||||
if len(raw) > 0 {
|
||||
if err := json.Unmarshal(raw, &perms); err != nil {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if len(perms) > 0 {
|
||||
out[userID] = perms
|
||||
}
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
package billing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// ErrFeatureNotGrantable is returned when a permission update names a key the company
|
||||
// owner is not allowed to toggle (shell chrome, plan-metering capabilities, always-on keys).
|
||||
var ErrFeatureNotGrantable = errors.New("feature not grantable")
|
||||
|
||||
// alwaysGrantedPrefixes are never restrictable: the app shell must keep working for
|
||||
// every member, and capability.* keys meter the plan (credits, SKU caps) rather than
|
||||
// describe UI access — restricting them per member would silently break billing math.
|
||||
var alwaysGrantedPrefixes = []string{"shell.", "capability."}
|
||||
|
||||
// alwaysGrantedKeys keep a restricted member out of a dead end: they can always reach
|
||||
// the dashboard landing page and their own profile (to change password / sign out).
|
||||
var alwaysGrantedKeys = map[string]bool{
|
||||
"dashboard.overview": true,
|
||||
"settings.profile": true,
|
||||
}
|
||||
|
||||
// IsGrantableFeatureKey reports whether a company owner may toggle key per member.
|
||||
func IsGrantableFeatureKey(key string) bool {
|
||||
key = strings.TrimSpace(key)
|
||||
if !IsKnownFeatureKey(key) {
|
||||
return false
|
||||
}
|
||||
if alwaysGrantedKeys[key] {
|
||||
return false
|
||||
}
|
||||
for _, prefix := range alwaysGrantedPrefixes {
|
||||
if strings.HasPrefix(key, prefix) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// GrantableFeatureKeys returns the catalog subset an owner may restrict, in catalog order.
|
||||
func GrantableFeatureKeys() []string {
|
||||
out := make([]string, 0, len(FeatureCatalogKeys))
|
||||
for _, key := range FeatureCatalogKeys {
|
||||
if IsGrantableFeatureKey(key) {
|
||||
out = append(out, key)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// MemberPermissionEntry is one grantable key for the owner-facing permission editor.
|
||||
type MemberPermissionEntry struct {
|
||||
Key string `json:"key"`
|
||||
Section string `json:"section"`
|
||||
// Parent is the nearest grantable ancestor key ("catalog.products" for
|
||||
// "catalog.products.tab_error"), so the editor can nest children under it.
|
||||
Parent string `json:"parent,omitempty"`
|
||||
// PlanAllowed is false when the company's plan already denies the key — the
|
||||
// editor shows it greyed out rather than pretending the owner can grant it.
|
||||
PlanAllowed bool `json:"plan_allowed"`
|
||||
}
|
||||
|
||||
// MemberPermissionCatalog is the payload for GET /api/team/permission-catalog.
|
||||
type MemberPermissionCatalog struct {
|
||||
Sections []MemberPermissionSection `json:"sections"`
|
||||
}
|
||||
|
||||
// MemberPermissionSection groups grantable keys under one dashboard area.
|
||||
type MemberPermissionSection struct {
|
||||
ID string `json:"id"`
|
||||
Entries []MemberPermissionEntry `json:"entries"`
|
||||
}
|
||||
|
||||
// grantableParent returns the nearest ancestor key that is itself grantable.
|
||||
func grantableParent(key string) string {
|
||||
parts := strings.Split(key, ".")
|
||||
for i := len(parts) - 1; i > 0; i-- {
|
||||
candidate := strings.Join(parts[:i], ".")
|
||||
if IsGrantableFeatureKey(candidate) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// PermissionCatalogForCompany lists the grantable keys grouped by section, annotated
|
||||
// with what the company's own plan already allows.
|
||||
func (s *Service) PermissionCatalogForCompany(ctx context.Context, companyID uuid.UUID) (MemberPermissionCatalog, error) {
|
||||
caps, err := s.CapabilitiesForCompany(ctx, companyID)
|
||||
if err != nil {
|
||||
return MemberPermissionCatalog{}, err
|
||||
}
|
||||
bySection := map[string][]MemberPermissionEntry{}
|
||||
order := make([]string, 0, len(FeatureSections))
|
||||
for _, key := range GrantableFeatureKeys() {
|
||||
section, _ := SectionOfFeature(key)
|
||||
if section == "" {
|
||||
section = "other"
|
||||
}
|
||||
if _, seen := bySection[section]; !seen {
|
||||
order = append(order, section)
|
||||
}
|
||||
bySection[section] = append(bySection[section], MemberPermissionEntry{
|
||||
Key: key,
|
||||
Section: section,
|
||||
Parent: grantableParent(key),
|
||||
PlanAllowed: caps.Features != nil && caps.Features[key],
|
||||
})
|
||||
}
|
||||
out := MemberPermissionCatalog{Sections: make([]MemberPermissionSection, 0, len(order))}
|
||||
for _, section := range order {
|
||||
out.Sections = append(out.Sections, MemberPermissionSection{ID: section, Entries: bySection[section]})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SanitizeMemberPermissions normalizes an incoming overlay to its canonical form:
|
||||
// only grantable keys survive, and only denials (false) are stored — an "allowed"
|
||||
// entry is the absence of a key. Returns ErrFeatureNotGrantable on an unknown or
|
||||
// protected key so a typo fails loudly instead of silently granting access.
|
||||
func SanitizeMemberPermissions(in map[string]bool) (map[string]bool, error) {
|
||||
out := map[string]bool{}
|
||||
for rawKey, allowed := range in {
|
||||
key := strings.TrimSpace(rawKey)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if allowed {
|
||||
// Allow is the default; storing it would only add drift.
|
||||
if !IsKnownFeatureKey(key) {
|
||||
return nil, fmt.Errorf("%w: %s", ErrUnknownFeatureKey, key)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !IsGrantableFeatureKey(key) {
|
||||
return nil, fmt.Errorf("%w: %s", ErrFeatureNotGrantable, key)
|
||||
}
|
||||
out[key] = false
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// MemberDeniesFeature reports whether the overlay denies key, honouring parent-prefix
|
||||
// denial (denying "catalog.products" also denies "catalog.products.tab_error").
|
||||
// Mirrors effectiveFeature() in apps/web/src/lib/plan-capabilities.ts.
|
||||
func MemberDeniesFeature(perms map[string]bool, key string) bool {
|
||||
if len(perms) == 0 {
|
||||
return false
|
||||
}
|
||||
if alwaysGrantedKeys[key] {
|
||||
return false
|
||||
}
|
||||
for _, prefix := range alwaysGrantedPrefixes {
|
||||
if strings.HasPrefix(key, prefix) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if denied, ok := perms[key]; ok && !denied {
|
||||
return true
|
||||
}
|
||||
parts := strings.Split(key, ".")
|
||||
for i := 1; i < len(parts); i++ {
|
||||
ancestor := strings.Join(parts[:i], ".")
|
||||
if denied, ok := perms[ancestor]; ok && !denied {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ApplyMemberPermissions intersects a company feature map with a member overlay.
|
||||
// Returns a new map (the input is left untouched) plus the sorted list of keys the
|
||||
// overlay turned off — the UI uses that to say "restricted by your administrator"
|
||||
// instead of "upgrade your plan".
|
||||
func ApplyMemberPermissions(features map[string]bool, perms map[string]bool) (map[string]bool, []string) {
|
||||
if len(perms) == 0 {
|
||||
return features, nil
|
||||
}
|
||||
out := make(map[string]bool, len(features))
|
||||
denied := make([]string, 0, len(perms))
|
||||
for key, allowed := range features {
|
||||
if allowed && MemberDeniesFeature(perms, key) {
|
||||
out[key] = false
|
||||
denied = append(denied, key)
|
||||
continue
|
||||
}
|
||||
out[key] = allowed
|
||||
}
|
||||
sort.Strings(denied)
|
||||
return out, denied
|
||||
}
|
||||
|
||||
// FeatureETag exposes the feature-map digest so callers outside the package (the /me
|
||||
// handler) can re-stamp it after narrowing a map with ApplyMemberPermissions.
|
||||
func FeatureETag(features map[string]bool) string { return featureETag(features) }
|
||||
|
||||
// MemberPermissions returns the stored overlay for one company member.
|
||||
//
|
||||
// The owner (companies.owner_user_id) is never restricted — they are the one who edits
|
||||
// these — so an owner always resolves to a nil overlay regardless of what is stored.
|
||||
// Reads memberships directly (rather than through the auth package) so capability
|
||||
// resolution stays a single self-contained query on the hot /me + /capabilities path.
|
||||
func (s *Service) MemberPermissions(ctx context.Context, companyID, userID uuid.UUID) (map[string]bool, error) {
|
||||
if s == nil || s.Pool == nil || companyID == uuid.Nil || userID == uuid.Nil {
|
||||
return nil, nil
|
||||
}
|
||||
var raw []byte
|
||||
var isOwner bool
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
SELECT COALESCE(m.permissions, '{}'::jsonb),
|
||||
(c.owner_user_id IS NOT NULL AND c.owner_user_id = m.user_id) AS is_owner
|
||||
FROM memberships m
|
||||
JOIN companies c ON c.id = m.company_id
|
||||
WHERE m.company_id = $1 AND m.user_id = $2`, companyID, userID).Scan(&raw, &isOwner)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
// Platform staff acting on a tenant have no membership row — unrestricted.
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
// Pre-migration (no permissions / owner_user_id column): treat as unrestricted
|
||||
// rather than locking every member out of the dashboard.
|
||||
if isUndefinedColumn(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if isOwner {
|
||||
return nil, nil
|
||||
}
|
||||
return decodeMemberPermissions(raw)
|
||||
}
|
||||
|
||||
func decodeMemberPermissions(raw []byte) (map[string]bool, error) {
|
||||
if len(raw) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var parsed map[string]bool
|
||||
if err := json.Unmarshal(raw, &parsed); err != nil {
|
||||
// A hand-edited / legacy blob must not brick the dashboard.
|
||||
return nil, nil
|
||||
}
|
||||
out := map[string]bool{}
|
||||
for key, allowed := range parsed {
|
||||
if !allowed && IsGrantableFeatureKey(key) {
|
||||
out[key] = false
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// CapabilitiesForMember returns the company capabilities narrowed by the caller's
|
||||
// per-member overlay. This is what the dashboard shell (nav, route guard, feature
|
||||
// gates) reads, so restricting a member here hides the surface everywhere at once.
|
||||
func (s *Service) CapabilitiesForMember(ctx context.Context, companyID, userID uuid.UUID) (Capabilities, error) {
|
||||
caps, err := s.CapabilitiesForCompany(ctx, companyID)
|
||||
if err != nil {
|
||||
return caps, err
|
||||
}
|
||||
perms, err := s.MemberPermissions(ctx, companyID, userID)
|
||||
if err != nil {
|
||||
return caps, err
|
||||
}
|
||||
if len(perms) == 0 {
|
||||
return caps, nil
|
||||
}
|
||||
features, denied := ApplyMemberPermissions(caps.Features, perms)
|
||||
caps.Features = features
|
||||
caps.MemberRestricted = true
|
||||
caps.MemberDeniedFeatures = denied
|
||||
caps.DisabledFeatures = mergeDisabledFeatures(caps.DisabledFeatures, denied)
|
||||
// Re-stamp the ETag: two members of one company now have different maps, and a
|
||||
// shared ETag would let one member's cached response satisfy another's request.
|
||||
caps.FeatureETag = featureETag(features)
|
||||
return caps, nil
|
||||
}
|
||||
|
||||
func mergeDisabledFeatures(existing, extra []string) []string {
|
||||
seen := make(map[string]bool, len(existing)+len(extra))
|
||||
out := make([]string, 0, len(existing)+len(extra))
|
||||
for _, list := range [][]string{existing, extra} {
|
||||
for _, key := range list {
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
out = append(out, key)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// AssertFeaturesForMember fails closed on the first key denied by the plan, the global
|
||||
// gates, or the member's own overlay.
|
||||
func (s *Service) AssertFeaturesForMember(ctx context.Context, companyID, userID uuid.UUID, keys ...string) error {
|
||||
if len(keys) == 0 {
|
||||
return nil
|
||||
}
|
||||
if err := s.AssertFeatures(ctx, companyID, keys...); err != nil {
|
||||
return err
|
||||
}
|
||||
perms, err := s.MemberPermissions(ctx, companyID, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, key := range keys {
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if MemberDeniesFeature(perms, key) {
|
||||
return fmt.Errorf("%w: %s", ErrMemberFeatureDenied, key)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ErrMemberFeatureDenied separates "your administrator turned this off" from
|
||||
// ErrFeatureDisabled ("your plan does not include this") so the UI can offer the right
|
||||
// next step — ask an admin vs. upgrade.
|
||||
var ErrMemberFeatureDenied = errors.New("member_feature_denied")
|
||||
@@ -0,0 +1,181 @@
|
||||
package billing
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIsGrantableFeatureKey(t *testing.T) {
|
||||
grantable := []string{
|
||||
"stores.hub",
|
||||
"settings.api_keys",
|
||||
"catalog.products",
|
||||
"catalog.products.tab_error",
|
||||
"marketing.campaigns.send",
|
||||
}
|
||||
for _, key := range grantable {
|
||||
if !IsGrantableFeatureKey(key) {
|
||||
t.Errorf("expected %q to be grantable", key)
|
||||
}
|
||||
}
|
||||
notGrantable := []string{
|
||||
"shell.navigation", // app chrome
|
||||
"capability.ai_credits", // plan metering, not UI access
|
||||
"dashboard.overview", // always-on landing page
|
||||
"settings.profile", // always-on: own password / sign out
|
||||
"catalog.not_a_real_key", // unknown
|
||||
"",
|
||||
}
|
||||
for _, key := range notGrantable {
|
||||
if IsGrantableFeatureKey(key) {
|
||||
t.Errorf("expected %q to NOT be grantable", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeMemberPermissions(t *testing.T) {
|
||||
t.Run("keeps only denials of grantable keys", func(t *testing.T) {
|
||||
got, err := SanitizeMemberPermissions(map[string]bool{
|
||||
"stores.hub": false,
|
||||
"settings.api_keys": false,
|
||||
"catalog.products": true, // allow is the default — never stored
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
want := map[string]bool{"stores.hub": false, "settings.api_keys": false}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got %v want %v", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects protected keys instead of silently dropping them", func(t *testing.T) {
|
||||
if _, err := SanitizeMemberPermissions(map[string]bool{"shell.navigation": false}); !errors.Is(err, ErrFeatureNotGrantable) {
|
||||
t.Fatalf("expected ErrFeatureNotGrantable, got %v", err)
|
||||
}
|
||||
if _, err := SanitizeMemberPermissions(map[string]bool{"dashboard.overview": false}); !errors.Is(err, ErrFeatureNotGrantable) {
|
||||
t.Fatalf("expected ErrFeatureNotGrantable for always-on key, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects unknown keys", func(t *testing.T) {
|
||||
if _, err := SanitizeMemberPermissions(map[string]bool{"nope.nope": false}); !errors.Is(err, ErrFeatureNotGrantable) {
|
||||
t.Fatalf("expected ErrFeatureNotGrantable, got %v", err)
|
||||
}
|
||||
if _, err := SanitizeMemberPermissions(map[string]bool{"nope.nope": true}); !errors.Is(err, ErrUnknownFeatureKey) {
|
||||
t.Fatalf("expected ErrUnknownFeatureKey, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestMemberDeniesFeature(t *testing.T) {
|
||||
perms := map[string]bool{"stores.hub": false, "catalog.products": false}
|
||||
|
||||
if !MemberDeniesFeature(perms, "stores.hub") {
|
||||
t.Error("direct denial should apply")
|
||||
}
|
||||
if !MemberDeniesFeature(perms, "catalog.products.tab_error") {
|
||||
t.Error("denying a parent must deny its descendants")
|
||||
}
|
||||
if MemberDeniesFeature(perms, "catalog.categories") {
|
||||
t.Error("sibling keys must stay allowed")
|
||||
}
|
||||
if MemberDeniesFeature(nil, "stores.hub") {
|
||||
t.Error("an empty overlay denies nothing")
|
||||
}
|
||||
|
||||
// Protected keys survive even a hand-edited overlay that names them.
|
||||
hostile := map[string]bool{"shell.navigation": false, "dashboard.overview": false, "capability.ai_credits": false}
|
||||
for key := range hostile {
|
||||
if MemberDeniesFeature(hostile, key) {
|
||||
t.Errorf("%q must never be deniable", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyMemberPermissions(t *testing.T) {
|
||||
features := map[string]bool{
|
||||
"stores.hub": true,
|
||||
"catalog.products": true,
|
||||
"catalog.products.tab_error": true,
|
||||
"marketing.campaigns": false, // already off by plan
|
||||
"dashboard.overview": true,
|
||||
}
|
||||
perms := map[string]bool{"stores.hub": false, "catalog.products": false, "marketing.campaigns": false}
|
||||
|
||||
got, denied := ApplyMemberPermissions(features, perms)
|
||||
|
||||
for _, key := range []string{"stores.hub", "catalog.products", "catalog.products.tab_error"} {
|
||||
if got[key] {
|
||||
t.Errorf("expected %q to be denied", key)
|
||||
}
|
||||
}
|
||||
if !got["dashboard.overview"] {
|
||||
t.Error("always-on keys must survive the overlay")
|
||||
}
|
||||
// Already-off plan keys are not reported as member denials — the member did not lose them.
|
||||
want := []string{"catalog.products", "catalog.products.tab_error", "stores.hub"}
|
||||
if !reflect.DeepEqual(denied, want) {
|
||||
t.Fatalf("denied = %v, want %v", denied, want)
|
||||
}
|
||||
// The input map must not be mutated — it is shared with the company-level cache.
|
||||
if !features["stores.hub"] {
|
||||
t.Error("ApplyMemberPermissions mutated its input")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyMemberPermissionsIsNoopWithoutOverlay(t *testing.T) {
|
||||
features := map[string]bool{"stores.hub": true}
|
||||
got, denied := ApplyMemberPermissions(features, nil)
|
||||
if len(denied) != 0 {
|
||||
t.Fatalf("expected no denials, got %v", denied)
|
||||
}
|
||||
if !reflect.DeepEqual(got, features) {
|
||||
t.Fatalf("expected the map unchanged, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGrantableFeatureKeysExcludeProtectedPrefixes(t *testing.T) {
|
||||
keys := GrantableFeatureKeys()
|
||||
if len(keys) == 0 {
|
||||
t.Fatal("expected a non-empty grantable catalog")
|
||||
}
|
||||
for _, key := range keys {
|
||||
if !IsGrantableFeatureKey(key) {
|
||||
t.Errorf("GrantableFeatureKeys returned non-grantable %q", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGrantableParent(t *testing.T) {
|
||||
if got := grantableParent("catalog.products.tab_error"); got != "catalog.products" {
|
||||
t.Errorf("got %q, want catalog.products", got)
|
||||
}
|
||||
// dashboard.overview is always-on, so its children have no grantable parent.
|
||||
if got := grantableParent("dashboard.stats"); got != "" {
|
||||
t.Errorf("got %q, want empty", got)
|
||||
}
|
||||
if got := grantableParent("stores.hub"); got != "" {
|
||||
t.Errorf("got %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeMemberPermissionsIsDefensive(t *testing.T) {
|
||||
// Garbage must not brick the dashboard — it resolves to "unrestricted".
|
||||
if got, _ := decodeMemberPermissions([]byte("not json")); got != nil {
|
||||
t.Errorf("expected nil for invalid JSON, got %v", got)
|
||||
}
|
||||
if got, _ := decodeMemberPermissions([]byte(`{}`)); got != nil {
|
||||
t.Errorf("expected nil for an empty overlay, got %v", got)
|
||||
}
|
||||
// Stale / protected keys are dropped at read time too.
|
||||
got, err := decodeMemberPermissions([]byte(`{"stores.hub":false,"shell.navigation":false,"gone.key":false,"feeds.list":true}`))
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
want := map[string]bool{"stores.hub": false}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,11 @@ type Capabilities struct {
|
||||
DisabledFeatures []string `json:"disabled_features"`
|
||||
FeatureETag string `json:"feature_etag"`
|
||||
Entitlements Entitlements `json:"entitlements"`
|
||||
// MemberRestricted is true when the company owner narrowed this member's access.
|
||||
// MemberDeniedFeatures lists the keys the plan allows but the member overlay denies,
|
||||
// so the UI can say "ask your administrator" instead of "upgrade your plan".
|
||||
MemberRestricted bool `json:"member_restricted,omitempty"`
|
||||
MemberDeniedFeatures []string `json:"member_denied_features,omitempty"`
|
||||
}
|
||||
|
||||
// FeatureGatesUpdate is the PUT /api/admin/feature-gates body.
|
||||
|
||||
@@ -58,6 +58,10 @@ type CreditsOverview struct {
|
||||
Sections map[string]bool `json:"sections,omitempty"`
|
||||
DisabledFeatures []string `json:"disabled_features,omitempty"`
|
||||
FeatureETag string `json:"feature_etag,omitempty"`
|
||||
// Per-member access overlay (settings > team). Set by the /me handler so the
|
||||
// dashboard can say "ask your administrator" rather than "upgrade your plan".
|
||||
MemberRestricted bool `json:"member_restricted,omitempty"`
|
||||
MemberDeniedFeatures []string `json:"member_denied_features,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Service) CreditsOverview(ctx context.Context, companyID uuid.UUID, lowThreshold int) (CreditsOverview, error) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/auth"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/billing"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
@@ -359,6 +360,17 @@ func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
if credits, err := s.Billing.CreditsOverview(r.Context(), cid, s.Config.LowCreditsThreshold); err == nil {
|
||||
// /me seeds the dashboard capability matrix before /billing/capabilities
|
||||
// lands, so the per-member overlay must be applied here too — otherwise a
|
||||
// restricted member sees a flash of the full nav on every page load.
|
||||
if perms, perr := s.Billing.MemberPermissions(r.Context(), cid, uid); perr == nil && len(perms) > 0 {
|
||||
features, denied := billing.ApplyMemberPermissions(credits.Features, perms)
|
||||
credits.Features = features
|
||||
credits.DisabledFeatures = append(credits.DisabledFeatures, denied...)
|
||||
credits.FeatureETag = billing.FeatureETag(features)
|
||||
credits.MemberRestricted = true
|
||||
credits.MemberDeniedFeatures = denied
|
||||
}
|
||||
out["credits"] = credits
|
||||
}
|
||||
if m, err := s.Auth.EnsureMembership(r.Context(), uid, cid); err == nil {
|
||||
|
||||
@@ -180,7 +180,13 @@ func (s *Server) handleListTeam(w http.ResponseWriter, r *http.Request) {
|
||||
Email string `json:"email"`
|
||||
Name *string `json:"name"`
|
||||
IsOwner bool `json:"is_owner"`
|
||||
// Restricted / DeniedCount summarise the owner-set access overlay so the team
|
||||
// table can badge restricted members without a request per row.
|
||||
Restricted bool `json:"restricted"`
|
||||
DeniedCount int `json:"denied_count"`
|
||||
}
|
||||
// Best effort: a missing permissions column (pre-migration) just means "no overlay".
|
||||
overlays, _ := s.Auth.MemberPermissionsByCompany(r.Context(), cid)
|
||||
out := make([]member, 0)
|
||||
for rows.Next() {
|
||||
var m member
|
||||
@@ -189,6 +195,10 @@ func (s *Server) handleListTeam(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
m.IsOwner = ownerUserID != nil && *ownerUserID == m.UserID
|
||||
if !m.IsOwner {
|
||||
m.DeniedCount = len(deniedKeys(overlays[m.UserID]))
|
||||
m.Restricted = m.DeniedCount > 0
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
resp := map[string]any{"members": out, "total": total, "limit": limit, "offset": offset}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/billing"
|
||||
)
|
||||
|
||||
// apiFeatureRoutes maps dashboard API path prefixes to the feature key that governs
|
||||
// them, mirroring NAV_FEATURE_BY_HREF / GATED_ROUTES in
|
||||
// apps/web/src/lib/plan-capabilities.ts. Longest prefix wins.
|
||||
//
|
||||
// Only surfaces an owner can actually restrict appear here: hiding a nav entry in the
|
||||
// browser is cosmetic, so every hidden surface needs the matching API closed too.
|
||||
var apiFeatureRoutes = map[string]string{
|
||||
"/api/products": "catalog.products",
|
||||
"/api/categories": "catalog.categories",
|
||||
"/api/variables": "catalog.categories",
|
||||
"/api/attributes": "catalog.attributes",
|
||||
"/api/standard-fields": "catalog.standard_fields",
|
||||
"/api/field-groups": "catalog.standard_fields",
|
||||
"/api/structured-descriptions": "catalog.structured_descriptions",
|
||||
"/api/vector-categories": "catalog.vector_categories",
|
||||
"/api/feeds": "feeds.list",
|
||||
"/api/export-feeds": "feeds.export_feeds",
|
||||
"/api/files": "feeds.uploads",
|
||||
"/api/processing": "processing.monitor",
|
||||
"/api/woocommerce": "stores.woocommerce",
|
||||
"/api/shopify": "stores.shopify",
|
||||
"/api/campaigns": "marketing.campaigns",
|
||||
"/api/marketing/calendar": "marketing.content_calendar",
|
||||
"/api/seo": "marketing.seo",
|
||||
"/api/brand": "marketing.brand_kit",
|
||||
"/api/integrations/ai": "integrations.ai",
|
||||
"/api/integrations/email": "integrations.email",
|
||||
"/api/email/send": "integrations.email",
|
||||
"/api/api-keys": "settings.api_keys",
|
||||
"/api/team": "settings.team",
|
||||
"/api/company": "settings.company",
|
||||
"/api/support": "support.center",
|
||||
"/api/billing/checkout": "billing.checkout",
|
||||
"/api/billing/portal": "billing.customer_portal",
|
||||
"/api/billing/usage": "billing.overview",
|
||||
"/api/billing/plans": "billing.plans_compare",
|
||||
"/api/billing/credit-packs": "billing.overview",
|
||||
"/api/billing/stripe": "billing.overview",
|
||||
}
|
||||
|
||||
// apiFeatureRoutePrefixes is apiFeatureRoutes' keys sorted longest-first so
|
||||
// "/api/marketing/calendar" wins over a shorter overlapping prefix.
|
||||
var apiFeatureRoutePrefixes = func() []string {
|
||||
out := make([]string, 0, len(apiFeatureRoutes))
|
||||
for prefix := range apiFeatureRoutes {
|
||||
out = append(out, prefix)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return len(out[i]) > len(out[j]) })
|
||||
return out
|
||||
}()
|
||||
|
||||
// featureKeyForAPIPath returns the governing feature key for a request path, or "".
|
||||
func featureKeyForAPIPath(path string) string {
|
||||
path = strings.TrimSuffix(path, "/")
|
||||
for _, prefix := range apiFeatureRoutePrefixes {
|
||||
if path == prefix || strings.HasPrefix(path, prefix+"/") || strings.HasPrefix(path, prefix+"?") {
|
||||
return apiFeatureRoutes[prefix]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// alwaysReachableAPIPaths stay open regardless of the overlay: the shell needs them to
|
||||
// render at all, and a restricted member must still be able to read their own session,
|
||||
// wallet and capability matrix (that matrix is what tells the UI what to hide).
|
||||
var alwaysReachableAPIPaths = []string{
|
||||
"/api/billing/credits",
|
||||
"/api/billing/capabilities",
|
||||
}
|
||||
|
||||
// RequireMemberFeature enforces the company owner's per-member access overlay on the
|
||||
// dashboard API. Feature gating by plan stays where it is (billing.AssertFeature at the
|
||||
// handler level); this middleware adds the per-member layer so a hidden nav entry is
|
||||
// genuinely unreachable rather than just invisible.
|
||||
//
|
||||
// Denials return 403 with code "member_feature_denied" so the client can distinguish
|
||||
// "ask your administrator" from a plan upgrade prompt.
|
||||
func (s *Server) RequireMemberFeature(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if s.Billing == nil {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
path := r.URL.Path
|
||||
for _, open := range alwaysReachableAPIPaths {
|
||||
if path == open {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
key := featureKeyForAPIPath(path)
|
||||
if key == "" {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
cid, okCompany := CompanyIDFromContext(r.Context())
|
||||
uid, okUser := UserIDFromContext(r.Context())
|
||||
if !okCompany || !okUser {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
perms, err := s.Billing.MemberPermissions(r.Context(), cid, uid)
|
||||
if err != nil {
|
||||
// Fail open on a lookup error: a transient DB blip must not lock the whole
|
||||
// tenant out of their dashboard. The UI-level gate still applies.
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
if billing.MemberDeniesFeature(perms, key) {
|
||||
FieldError(w, http.StatusForbidden,
|
||||
"your administrator has turned off access to this area",
|
||||
"member_feature_denied", nil)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/billing"
|
||||
)
|
||||
|
||||
func TestFeatureKeyForAPIPath(t *testing.T) {
|
||||
cases := []struct {
|
||||
path string
|
||||
want string
|
||||
}{
|
||||
{"/api/products", "catalog.products"},
|
||||
{"/api/products/", "catalog.products"},
|
||||
{"/api/products/8f14e45f-ceea-467a-9e0f-000000000000", "catalog.products"},
|
||||
{"/api/api-keys", "settings.api_keys"},
|
||||
{"/api/api-keys/42", "settings.api_keys"},
|
||||
{"/api/marketing/calendar", "marketing.content_calendar"},
|
||||
{"/api/marketing/calendar/prepare", "marketing.content_calendar"},
|
||||
{"/api/integrations/ai/prompts", "integrations.ai"},
|
||||
{"/api/integrations/email/test", "integrations.email"},
|
||||
{"/api/woocommerce/orders", "stores.woocommerce"},
|
||||
{"/api/billing/checkout", "billing.checkout"},
|
||||
// Unmapped paths stay open — the middleware must not guess.
|
||||
{"/api/auth/me", ""},
|
||||
{"/api/billing/credits", ""},
|
||||
{"/api/billing/capabilities", ""},
|
||||
{"/api/unknown-thing", ""},
|
||||
{"/api", ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := featureKeyForAPIPath(tc.path); got != tc.want {
|
||||
t.Errorf("featureKeyForAPIPath(%q) = %q, want %q", tc.path, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A prefix must never match a longer sibling path segment: /api/feeds governs
|
||||
// feeds.list, but /api/feeds-something-else is not a feeds route.
|
||||
func TestFeatureKeyForAPIPathDoesNotMatchPartialSegments(t *testing.T) {
|
||||
if got := featureKeyForAPIPath("/api/feedsomething"); got != "" {
|
||||
t.Errorf("got %q, want empty", got)
|
||||
}
|
||||
if got := featureKeyForAPIPath("/api/products-export"); got != "" {
|
||||
t.Errorf("got %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Every key the middleware enforces must be one the owner can actually toggle,
|
||||
// otherwise a route could be blocked with no way to unblock it.
|
||||
func TestAPIFeatureRoutesAreGrantable(t *testing.T) {
|
||||
for path, key := range apiFeatureRoutes {
|
||||
if !billing.IsKnownFeatureKey(key) {
|
||||
t.Errorf("%s maps to unknown feature key %q", path, key)
|
||||
continue
|
||||
}
|
||||
if !billing.IsGrantableFeatureKey(key) {
|
||||
t.Errorf("%s maps to %q, which the owner cannot toggle", path, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The capability matrix itself must stay reachable — it is what tells the
|
||||
// dashboard which surfaces to hide.
|
||||
func TestAlwaysReachablePathsAreNotGated(t *testing.T) {
|
||||
for _, path := range alwaysReachableAPIPaths {
|
||||
if got := featureKeyForAPIPath(path); got != "" {
|
||||
t.Errorf("%s resolved to feature %q but must stay open", path, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// GET /api/billing/capabilities — effective plan ∩ global features for the active company.
|
||||
// GET /api/billing/capabilities — effective plan ∩ global ∩ member features for the caller.
|
||||
func (s *Server) handleGetCapabilities(w http.ResponseWriter, r *http.Request) {
|
||||
if s.Billing == nil {
|
||||
Error(w, http.StatusServiceUnavailable, "billing unavailable")
|
||||
@@ -22,7 +22,10 @@ func (s *Server) handleGetCapabilities(w http.ResponseWriter, r *http.Request) {
|
||||
Error(w, http.StatusUnauthorized, "company required")
|
||||
return
|
||||
}
|
||||
caps, err := s.Billing.CapabilitiesForCompany(r.Context(), cid)
|
||||
// Member-aware: the company owner's per-member overlay narrows what this caller
|
||||
// sees, so nav / route guard / feature gates all hide the same surfaces at once.
|
||||
uid, _ := UserIDFromContext(r.Context())
|
||||
caps, err := s.Billing.CapabilitiesForMember(r.Context(), cid, uid)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "failed to load capabilities")
|
||||
return
|
||||
|
||||
@@ -436,6 +436,7 @@ func (s *Server) Router() http.Handler {
|
||||
r.Route("/api", func(r chi.Router) {
|
||||
r.Use(s.RequireSession)
|
||||
r.Use(s.RequireCompany)
|
||||
r.Use(s.RequireMemberFeature)
|
||||
r.Use(s.RateLimitMarketing)
|
||||
r.Use(s.RateLimitAIProbes)
|
||||
r.Use(s.RateLimitV1Process)
|
||||
@@ -453,8 +454,11 @@ func (s *Server) Router() http.Handler {
|
||||
r.Get("/team/invites", s.handleListInvites)
|
||||
r.Delete("/team/invites/{inviteID}", s.handleRevokeInvite)
|
||||
r.Post("/team/transfer-ownership", s.handleTransferOwnership)
|
||||
r.Get("/team/permission-catalog", s.handleGetPermissionCatalog)
|
||||
r.Patch("/team/{userID}", s.handleUpdateMemberRole)
|
||||
r.Delete("/team/{userID}", s.handleRemoveMember)
|
||||
r.Get("/team/{userID}/permissions", s.handleGetMemberPermissions)
|
||||
r.Put("/team/{userID}/permissions", s.handlePutMemberPermissions)
|
||||
|
||||
r.Get("/api-keys", s.handleListAPIKeys)
|
||||
r.Post("/api-keys", s.handleCreateAPIKey)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
-- +goose Up
|
||||
-- Per-member access overlay set by the company owner.
|
||||
--
|
||||
-- Sparse JSONB map of dashboard feature key -> false. Absent key means "inherit"
|
||||
-- (allowed, still subject to the plan). The overlay is purely restrictive: effective
|
||||
-- access = plan features ∩ global gates ∩ member overlay, so a member can never be
|
||||
-- granted more than the company's plan allows and no privilege escalation is possible.
|
||||
--
|
||||
-- The company owner (companies.owner_user_id) is never restricted — the owner is the
|
||||
-- one who edits these — so their row's overlay is ignored at resolve time.
|
||||
|
||||
ALTER TABLE memberships
|
||||
ADD COLUMN IF NOT EXISTS permissions JSONB NOT NULL DEFAULT '{}'::jsonb;
|
||||
|
||||
-- Partial index: only restricted members carry a non-empty overlay, and the common
|
||||
-- read is "does this member have any restriction at all".
|
||||
CREATE INDEX IF NOT EXISTS memberships_restricted_idx
|
||||
ON memberships (company_id, user_id)
|
||||
WHERE permissions <> '{}'::jsonb;
|
||||
|
||||
-- +goose Down
|
||||
DROP INDEX IF EXISTS memberships_restricted_idx;
|
||||
ALTER TABLE memberships DROP COLUMN IF EXISTS permissions;
|
||||
@@ -35,6 +35,10 @@ export type CreditsLike = {
|
||||
sections?: Record<string, boolean>;
|
||||
disabled_features?: string[];
|
||||
feature_etag?: string;
|
||||
/** True when the company owner narrowed this member's access (settings > team). */
|
||||
member_restricted?: boolean;
|
||||
/** Keys the plan allows but the member overlay denies. */
|
||||
member_denied_features?: string[];
|
||||
};
|
||||
|
||||
export type UpgradeCta = {
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<script lang="ts">
|
||||
import { Lock } from "@lucide/svelte";
|
||||
import { i18n } from "$lib/i18n";
|
||||
|
||||
let {
|
||||
featureKey = undefined,
|
||||
compact = false
|
||||
}: {
|
||||
/** The denied feature key — shown as a hint so an admin knows what to re-enable. */
|
||||
featureKey?: string;
|
||||
/** Tighter spacing when embedded under an existing page heading. */
|
||||
compact?: boolean;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={compact ? "space-y-4" : "mx-auto max-w-2xl space-y-4 py-6"}
|
||||
data-testid="access-restricted-panel"
|
||||
>
|
||||
<div class="flex gap-4 rounded-lg border border-border bg-muted/30 px-5 py-5">
|
||||
<div
|
||||
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Lock class="h-5 w-5" />
|
||||
</div>
|
||||
<div class="min-w-0 space-y-1.5">
|
||||
<h2 class="text-base font-semibold text-foreground">
|
||||
{i18n.t("access.restricted.title")}
|
||||
</h2>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{i18n.t("access.restricted.message")}
|
||||
</p>
|
||||
{#if featureKey}
|
||||
<p class="pt-1 text-xs text-muted-foreground">
|
||||
{i18n.t("access.restricted.featureHint", { feature: featureKey })}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -9,6 +9,7 @@
|
||||
} from "$lib/plan-capabilities";
|
||||
import { upgradeMessageForFeature } from "$lib/plan-upgrade-message";
|
||||
import PlanUpgradePanel from "$lib/components/PlanUpgradePanel.svelte";
|
||||
import AccessRestrictedPanel from "$lib/components/AccessRestrictedPanel.svelte";
|
||||
import Spinner from "$lib/components/Spinner.svelte";
|
||||
import { i18n } from "$lib/i18n";
|
||||
|
||||
@@ -30,6 +31,8 @@
|
||||
const allowed = $derived(
|
||||
!gated || !featureKey || (!awaitingMatrix && planCapabilities.can(featureKey))
|
||||
);
|
||||
/** Owner-set member restriction — a plan upgrade would not unlock it. */
|
||||
const memberDenied = $derived(Boolean(featureKey) && planCapabilities.memberDenied(featureKey!));
|
||||
const gate = $derived(
|
||||
featureKey
|
||||
? upgradeMessageForFeature(featureKey, authSession.isCompanyAdmin, {
|
||||
@@ -48,6 +51,8 @@
|
||||
</div>
|
||||
{:else if allowed}
|
||||
{@render children()}
|
||||
{:else if memberDenied}
|
||||
<AccessRestrictedPanel featureKey={featureKey ?? undefined} />
|
||||
{:else if gate}
|
||||
<PlanUpgradePanel
|
||||
title={gate.title}
|
||||
|
||||
@@ -4,6 +4,11 @@ export type {
|
||||
ProductListURLFilters,
|
||||
ProductTab
|
||||
} from "../../products-search";
|
||||
export {
|
||||
coerceImageUrl,
|
||||
coerceImageUrlList,
|
||||
productImageUrls
|
||||
} from "../../product-images";
|
||||
export {
|
||||
hasExplicitProductTab,
|
||||
isProductTab,
|
||||
@@ -847,39 +852,6 @@ function digAttrBag(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Collect http(s) image URLs from mapped_data (main_image + more_images). */
|
||||
export function productImageUrls(product: ProductRow | null | undefined): string[] {
|
||||
const mapped = asRecord(product?.mapped_data);
|
||||
if (!mapped) return [];
|
||||
const out: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
const push = (raw: unknown) => {
|
||||
if (typeof raw === "string") {
|
||||
const url = raw.trim();
|
||||
if (!/^https?:\/\//i.test(url) || seen.has(url)) return;
|
||||
seen.add(url);
|
||||
out.push(url);
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(raw)) {
|
||||
for (const item of raw) push(item);
|
||||
return;
|
||||
}
|
||||
const rec = asRecord(raw);
|
||||
if (!rec) return;
|
||||
for (const key of ["url", "src", "href", "link"]) {
|
||||
if (rec[key] != null) push(rec[key]);
|
||||
}
|
||||
};
|
||||
for (const key of ["main_image", "mainImage", "image", "image_url", "imageUrl"]) {
|
||||
if (mapped[key] != null) push(mapped[key]);
|
||||
}
|
||||
for (const key of ["more_images", "moreImages", "images", "additional_images"]) {
|
||||
if (mapped[key] != null) push(mapped[key]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** True when product carries an EPREL id or enriched energy-label payload. */
|
||||
export function productHasEprel(product: ProductRow | null | undefined): boolean {
|
||||
if (!product) return false;
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
<script lang="ts">
|
||||
import { api } from "$lib/api";
|
||||
import { i18n } from "$lib/i18n";
|
||||
import { notifyApiError } from "$lib/notify";
|
||||
import { planCapabilities } from "$lib/plan-capabilities.svelte";
|
||||
import { sectionLabel } from "$lib/plan-feature-catalog";
|
||||
import { Badge, Button, Checkbox, Dialog, Input, Spinner } from "$lib/components/ui";
|
||||
import Alert from "$lib/components/Alert.svelte";
|
||||
import {
|
||||
catalogKeys,
|
||||
denyAll,
|
||||
filterCatalog,
|
||||
isDenied,
|
||||
permissionSummary,
|
||||
samePermissions,
|
||||
setPermissionAllowed,
|
||||
type MemberPermissionsView,
|
||||
type PermissionCatalog
|
||||
} from "$lib/member-permissions";
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
member,
|
||||
canEdit = false,
|
||||
onSaved = undefined
|
||||
}: {
|
||||
open?: boolean;
|
||||
/** The teammate being edited — null while the dialog is closed. */
|
||||
member: { user_id: string; email: string; is_owner?: boolean } | null;
|
||||
/** Only the company owner may change permissions; others get a read-only view. */
|
||||
canEdit?: boolean;
|
||||
onSaved?: (view: MemberPermissionsView) => void;
|
||||
} = $props();
|
||||
|
||||
let catalog = $state<PermissionCatalog | null>(null);
|
||||
let denied = $state<string[]>([]);
|
||||
let savedDenied = $state<string[]>([]);
|
||||
let loading = $state(false);
|
||||
let saving = $state(false);
|
||||
let loadError = $state<string | null>(null);
|
||||
let search = $state("");
|
||||
let isOwnerMember = $state(false);
|
||||
|
||||
const allKeys = $derived(catalogKeys(catalog));
|
||||
const summary = $derived(permissionSummary(catalog, denied));
|
||||
const dirty = $derived(!samePermissions(denied, savedDenied));
|
||||
const readOnly = $derived(!canEdit || isOwnerMember);
|
||||
|
||||
function labelFor(key: string): string {
|
||||
const translated = i18n.t(`plan.feature.${key}`);
|
||||
// Untranslated keys fall back to the raw key rather than an empty cell.
|
||||
return translated === `plan.feature.${key}` ? key : translated;
|
||||
}
|
||||
|
||||
const visibleSections = $derived(filterCatalog(catalog, search, labelFor));
|
||||
|
||||
/** Reload whenever the dialog opens for a member (never on every keystroke). */
|
||||
$effect(() => {
|
||||
if (!open || !member) return;
|
||||
const userId = member.user_id;
|
||||
const ac = new AbortController();
|
||||
loading = true;
|
||||
loadError = null;
|
||||
search = "";
|
||||
void (async () => {
|
||||
try {
|
||||
const [catalogPayload, view] = await Promise.all([
|
||||
api<PermissionCatalog>("/api/team/permission-catalog", { signal: ac.signal }),
|
||||
api<MemberPermissionsView>(`/api/team/${userId}/permissions`, { signal: ac.signal })
|
||||
]);
|
||||
catalog = catalogPayload;
|
||||
denied = [...(view.denied ?? [])];
|
||||
savedDenied = [...(view.denied ?? [])];
|
||||
isOwnerMember = Boolean(view.is_owner);
|
||||
} catch (err) {
|
||||
if ((err as { name?: string })?.name === "AbortError") return;
|
||||
loadError = i18n.t("settings.permissions.loadFailed");
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
})();
|
||||
return () => ac.abort();
|
||||
});
|
||||
|
||||
function toggle(key: string, nextAllowed: boolean) {
|
||||
if (readOnly) return;
|
||||
denied = setPermissionAllowed(denied, key, nextAllowed, allKeys);
|
||||
}
|
||||
|
||||
function allowEverything() {
|
||||
if (readOnly) return;
|
||||
denied = [];
|
||||
}
|
||||
|
||||
function denyEverything() {
|
||||
if (readOnly) return;
|
||||
denied = denyAll(catalog);
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!member || readOnly || saving) return;
|
||||
saving = true;
|
||||
try {
|
||||
const view = await api<MemberPermissionsView>(`/api/team/${member.user_id}/permissions`, {
|
||||
method: "PUT",
|
||||
body: { denied }
|
||||
});
|
||||
savedDenied = [...(view.denied ?? [])];
|
||||
denied = [...(view.denied ?? [])];
|
||||
// The editor may have just restricted themselves out of a section elsewhere in
|
||||
// this tab — refetch so the sidebar and route guard agree with the server.
|
||||
await planCapabilities.refresh(undefined, true);
|
||||
onSaved?.(view);
|
||||
open = false;
|
||||
} catch (err) {
|
||||
notifyApiError(err, i18n.t("settings.permissions.saveFailed"));
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Indent nested keys so "Products - Error tab" reads as a child of "Products". */
|
||||
function indentClass(key: string): string {
|
||||
const depth = key.split(".").length - 2;
|
||||
if (depth <= 0) return "";
|
||||
return depth === 1 ? "pl-6" : "pl-12";
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog
|
||||
bind:open
|
||||
title={i18n.t("settings.permissions.title", { email: member?.email ?? "" })}
|
||||
description={i18n.t("settings.permissions.description")}
|
||||
class="sm:max-w-2xl sm:min-w-0"
|
||||
>
|
||||
<div class="space-y-4 py-2">
|
||||
{#if loading}
|
||||
<div class="flex justify-center py-10">
|
||||
<Spinner />
|
||||
</div>
|
||||
{:else if loadError}
|
||||
<Alert tone="error" message={loadError} />
|
||||
{:else}
|
||||
{#if isOwnerMember}
|
||||
<Alert tone="info" message={i18n.t("settings.permissions.ownerNotice")} />
|
||||
{:else if !canEdit}
|
||||
<Alert tone="info" message={i18n.t("settings.permissions.ownerOnly")} />
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<p class="text-sm text-muted-foreground" data-testid="permissions-summary">
|
||||
{i18n.t("settings.permissions.summary", {
|
||||
allowed: summary.allowed,
|
||||
total: summary.total
|
||||
})}
|
||||
</p>
|
||||
{#if !readOnly}
|
||||
<div class="flex gap-2">
|
||||
<Button variant="outline" size="sm" data-testid="permissions-allow-all" onclick={allowEverything}>
|
||||
{i18n.t("settings.permissions.allowAll")}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" data-testid="permissions-deny-all" onclick={denyEverything}>
|
||||
{i18n.t("settings.permissions.denyAll")}
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Input
|
||||
type="search"
|
||||
bind:value={search}
|
||||
placeholder={i18n.t("settings.permissions.searchPlaceholder")}
|
||||
aria-label={i18n.t("settings.permissions.searchPlaceholder")}
|
||||
/>
|
||||
|
||||
<div class="max-h-[24rem] space-y-5 overflow-y-auto pr-1">
|
||||
{#if visibleSections.length === 0}
|
||||
<p class="py-8 text-center text-sm text-muted-foreground">
|
||||
{i18n.t("settings.permissions.noMatches")}
|
||||
</p>
|
||||
{/if}
|
||||
{#each visibleSections as section (section.id)}
|
||||
<section class="space-y-1">
|
||||
<h3
|
||||
class="sticky top-0 bg-background py-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground"
|
||||
>
|
||||
{sectionLabel(section.id)}
|
||||
</h3>
|
||||
{#each section.entries as entry (entry.key)}
|
||||
{@const allowed = entry.plan_allowed && !isDenied(denied, entry.key)}
|
||||
{@const locked = !entry.plan_allowed}
|
||||
<div
|
||||
class="flex items-center gap-3 rounded-md px-2 py-1.5 hover:bg-muted/40 {indentClass(
|
||||
entry.key
|
||||
)}"
|
||||
>
|
||||
<Checkbox
|
||||
id={`perm-${entry.key}`}
|
||||
checked={allowed}
|
||||
disabled={readOnly || locked}
|
||||
aria-label={labelFor(entry.key)}
|
||||
data-testid={`perm-${entry.key}`}
|
||||
onchange={() => toggle(entry.key, !allowed)}
|
||||
/>
|
||||
<label
|
||||
for={`perm-${entry.key}`}
|
||||
class="min-w-0 flex-1 cursor-pointer text-sm {locked
|
||||
? 'text-muted-foreground'
|
||||
: 'text-foreground'}"
|
||||
>
|
||||
{labelFor(entry.key)}
|
||||
</label>
|
||||
{#if locked}
|
||||
<Badge variant="secondary">{i18n.t("settings.permissions.planLocked")}</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</section>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#snippet footer()}
|
||||
<Button variant="outline" onclick={() => (open = false)}>
|
||||
{readOnly ? i18n.t("common.close") : i18n.t("common.cancel")}
|
||||
</Button>
|
||||
{#if !readOnly}
|
||||
<Button
|
||||
data-testid="permissions-save"
|
||||
disabled={saving || loading || !dirty}
|
||||
loading={saving}
|
||||
onclick={save}
|
||||
>
|
||||
{i18n.t("settings.permissions.save")}
|
||||
</Button>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</Dialog>
|
||||
@@ -5658,4 +5658,25 @@ export const de: MessageDict = {
|
||||
"files.empty": "Noch keine Uploads.",
|
||||
"products.filters.sortProducts": "Produkte sortieren",
|
||||
"products.table.options": "Optionen",
|
||||
"access.restricted.title": "Zugriff eingeschränkt",
|
||||
"access.restricted.message": "Der Inhaber Ihres Unternehmens hat den Zugriff auf diesen Bereich deaktiviert. Bitten Sie ihn, ihn bei Bedarf freizuschalten.",
|
||||
"access.restricted.featureHint": "Berechtigung: {feature}",
|
||||
"settings.permissions.action": "Berechtigungen",
|
||||
"settings.permissions.title": "Berechtigungen für {email}",
|
||||
"settings.permissions.description": "Legen Sie fest, was dieses Teammitglied öffnen darf. Ein deaktivierter Bereich wird in der Seitenleiste ausgeblendet und in der API gesperrt.",
|
||||
"settings.permissions.ownerNotice": "Der Unternehmensinhaber hat immer vollen Zugriff und kann nicht eingeschränkt werden.",
|
||||
"settings.permissions.ownerOnly": "Nur der Unternehmensinhaber kann Berechtigungen ändern.",
|
||||
"settings.permissions.summary": "{allowed} von {total} Bereichen erlaubt",
|
||||
"settings.permissions.allowAll": "Alle erlauben",
|
||||
"settings.permissions.denyAll": "Alle sperren",
|
||||
"settings.permissions.planLocked": "Nicht in Ihrem Tarif",
|
||||
"settings.permissions.searchPlaceholder": "Bereiche suchen…",
|
||||
"settings.permissions.noMatches": "Keine Bereiche passen zu dieser Suche.",
|
||||
"settings.permissions.save": "Berechtigungen speichern",
|
||||
"settings.permissions.saved": "Berechtigungen für {email} aktualisiert",
|
||||
"settings.permissions.saveFailed": "Berechtigungen konnten nicht aktualisiert werden",
|
||||
"settings.permissions.loadFailed": "Berechtigungen konnten nicht geladen werden",
|
||||
"settings.permissions.restrictedBadge": "Eingeschränkt",
|
||||
"settings.permissions.restrictedHint": "{count} Bereiche deaktiviert",
|
||||
"settings.tabLockedByAdmin": "Vom Unternehmensinhaber deaktiviert"
|
||||
};
|
||||
|
||||
@@ -5743,5 +5743,26 @@ export const en: MessageDict = {
|
||||
"files.loading": "Loading uploads…",
|
||||
"files.empty": "No uploads yet.",
|
||||
"products.filters.sortProducts": "Sort Products",
|
||||
"products.table.options": "Options"
|
||||
"products.table.options": "Options",
|
||||
"access.restricted.title": "Access restricted",
|
||||
"access.restricted.message": "Your company owner has turned off access to this area. Ask them to enable it if you need it.",
|
||||
"access.restricted.featureHint": "Permission: {feature}",
|
||||
"settings.permissions.action": "Permissions",
|
||||
"settings.permissions.title": "Permissions for {email}",
|
||||
"settings.permissions.description": "Choose what this teammate can open. Turning an area off hides it from their sidebar and blocks it in the API.",
|
||||
"settings.permissions.ownerNotice": "The company owner always has full access and cannot be restricted.",
|
||||
"settings.permissions.ownerOnly": "Only the company owner can change permissions.",
|
||||
"settings.permissions.summary": "{allowed} of {total} areas allowed",
|
||||
"settings.permissions.allowAll": "Allow all",
|
||||
"settings.permissions.denyAll": "Deny all",
|
||||
"settings.permissions.planLocked": "Not in your plan",
|
||||
"settings.permissions.searchPlaceholder": "Search areas…",
|
||||
"settings.permissions.noMatches": "No areas match that search.",
|
||||
"settings.permissions.save": "Save permissions",
|
||||
"settings.permissions.saved": "Permissions updated for {email}",
|
||||
"settings.permissions.saveFailed": "Could not update permissions",
|
||||
"settings.permissions.loadFailed": "Could not load permissions",
|
||||
"settings.permissions.restrictedBadge": "Restricted",
|
||||
"settings.permissions.restrictedHint": "{count} areas turned off",
|
||||
"settings.tabLockedByAdmin": "Turned off by your company owner"
|
||||
};
|
||||
|
||||
@@ -5667,4 +5667,25 @@ export const es: MessageDict = {
|
||||
"files.empty": "Aún no hay cargas.",
|
||||
"products.filters.sortProducts": "Ordenar productos",
|
||||
"products.table.options": "Opciones",
|
||||
"access.restricted.title": "Acceso restringido",
|
||||
"access.restricted.message": "El propietario de tu empresa ha desactivado el acceso a esta sección. Pídele que la active si la necesitas.",
|
||||
"access.restricted.featureHint": "Permiso: {feature}",
|
||||
"settings.permissions.action": "Permisos",
|
||||
"settings.permissions.title": "Permisos de {email}",
|
||||
"settings.permissions.description": "Elige qué puede abrir este miembro. Al desactivar una sección se oculta de su menú lateral y se bloquea en la API.",
|
||||
"settings.permissions.ownerNotice": "El propietario de la empresa siempre tiene acceso completo y no se puede restringir.",
|
||||
"settings.permissions.ownerOnly": "Solo el propietario de la empresa puede cambiar los permisos.",
|
||||
"settings.permissions.summary": "{allowed} de {total} secciones permitidas",
|
||||
"settings.permissions.allowAll": "Permitir todo",
|
||||
"settings.permissions.denyAll": "Denegar todo",
|
||||
"settings.permissions.planLocked": "No incluido en tu plan",
|
||||
"settings.permissions.searchPlaceholder": "Buscar secciones…",
|
||||
"settings.permissions.noMatches": "Ninguna sección coincide con la búsqueda.",
|
||||
"settings.permissions.save": "Guardar permisos",
|
||||
"settings.permissions.saved": "Permisos actualizados para {email}",
|
||||
"settings.permissions.saveFailed": "No se pudieron actualizar los permisos",
|
||||
"settings.permissions.loadFailed": "No se pudieron cargar los permisos",
|
||||
"settings.permissions.restrictedBadge": "Restringido",
|
||||
"settings.permissions.restrictedHint": "{count} secciones desactivadas",
|
||||
"settings.tabLockedByAdmin": "Desactivado por el propietario de tu empresa"
|
||||
};
|
||||
|
||||
@@ -5667,4 +5667,25 @@ export const fr: MessageDict = {
|
||||
"files.empty": "Aucun téléversement pour l’instant.",
|
||||
"products.filters.sortProducts": "Trier les produits",
|
||||
"products.table.options": "Options",
|
||||
"access.restricted.title": "Accès restreint",
|
||||
"access.restricted.message": "Le propriétaire de votre entreprise a désactivé l’accès à cette section. Demandez-lui de l’activer si vous en avez besoin.",
|
||||
"access.restricted.featureHint": "Autorisation : {feature}",
|
||||
"settings.permissions.action": "Autorisations",
|
||||
"settings.permissions.title": "Autorisations de {email}",
|
||||
"settings.permissions.description": "Choisissez ce que ce membre peut ouvrir. Désactiver une section la masque dans sa barre latérale et la bloque dans l’API.",
|
||||
"settings.permissions.ownerNotice": "Le propriétaire de l’entreprise a toujours un accès complet et ne peut pas être restreint.",
|
||||
"settings.permissions.ownerOnly": "Seul le propriétaire de l’entreprise peut modifier les autorisations.",
|
||||
"settings.permissions.summary": "{allowed} sur {total} sections autorisées",
|
||||
"settings.permissions.allowAll": "Tout autoriser",
|
||||
"settings.permissions.denyAll": "Tout refuser",
|
||||
"settings.permissions.planLocked": "Absent de votre forfait",
|
||||
"settings.permissions.searchPlaceholder": "Rechercher des sections…",
|
||||
"settings.permissions.noMatches": "Aucune section ne correspond à cette recherche.",
|
||||
"settings.permissions.save": "Enregistrer les autorisations",
|
||||
"settings.permissions.saved": "Autorisations mises à jour pour {email}",
|
||||
"settings.permissions.saveFailed": "Impossible de mettre à jour les autorisations",
|
||||
"settings.permissions.loadFailed": "Impossible de charger les autorisations",
|
||||
"settings.permissions.restrictedBadge": "Restreint",
|
||||
"settings.permissions.restrictedHint": "{count} sections désactivées",
|
||||
"settings.tabLockedByAdmin": "Désactivé par le propriétaire de votre entreprise"
|
||||
};
|
||||
|
||||
@@ -5667,4 +5667,25 @@ export const it: MessageDict = {
|
||||
"files.empty": "Nessun caricamento ancora.",
|
||||
"products.filters.sortProducts": "Ordina prodotti",
|
||||
"products.table.options": "Opzioni",
|
||||
"access.restricted.title": "Accesso limitato",
|
||||
"access.restricted.message": "Il proprietario della tua azienda ha disattivato l’accesso a questa sezione. Chiedigli di abilitarla se ti serve.",
|
||||
"access.restricted.featureHint": "Autorizzazione: {feature}",
|
||||
"settings.permissions.action": "Autorizzazioni",
|
||||
"settings.permissions.title": "Autorizzazioni per {email}",
|
||||
"settings.permissions.description": "Scegli cosa può aprire questo membro del team. Disattivare una sezione la nasconde dalla barra laterale e la blocca nell’API.",
|
||||
"settings.permissions.ownerNotice": "Il proprietario dell’azienda ha sempre accesso completo e non può essere limitato.",
|
||||
"settings.permissions.ownerOnly": "Solo il proprietario dell’azienda può modificare le autorizzazioni.",
|
||||
"settings.permissions.summary": "{allowed} di {total} sezioni consentite",
|
||||
"settings.permissions.allowAll": "Consenti tutto",
|
||||
"settings.permissions.denyAll": "Nega tutto",
|
||||
"settings.permissions.planLocked": "Non incluso nel tuo piano",
|
||||
"settings.permissions.searchPlaceholder": "Cerca sezioni…",
|
||||
"settings.permissions.noMatches": "Nessuna sezione corrisponde alla ricerca.",
|
||||
"settings.permissions.save": "Salva autorizzazioni",
|
||||
"settings.permissions.saved": "Autorizzazioni aggiornate per {email}",
|
||||
"settings.permissions.saveFailed": "Impossibile aggiornare le autorizzazioni",
|
||||
"settings.permissions.loadFailed": "Impossibile caricare le autorizzazioni",
|
||||
"settings.permissions.restrictedBadge": "Limitato",
|
||||
"settings.permissions.restrictedHint": "{count} sezioni disattivate",
|
||||
"settings.tabLockedByAdmin": "Disattivato dal proprietario della tua azienda"
|
||||
};
|
||||
|
||||
@@ -5667,4 +5667,25 @@ export const ja: MessageDict = {
|
||||
"files.empty": "ã¾ã アップãƒÂードã¯ã‚りã¾ã›ん。",
|
||||
"products.filters.sortProducts": "商å“Âを並ã¹替ãˆ",
|
||||
"products.table.options": "オプション",
|
||||
"access.restricted.title": "アクセスが制限されています",
|
||||
"access.restricted.message": "会社のオーナーがこのエリアへのアクセスを無効にしています。必要な場合は有効化を依頼してください。",
|
||||
"access.restricted.featureHint": "権限: {feature}",
|
||||
"settings.permissions.action": "権限",
|
||||
"settings.permissions.title": "{email} の権限",
|
||||
"settings.permissions.description": "このメンバーが開ける範囲を選びます。オフにしたエリアはサイドバーから非表示になり、API でもブロックされます。",
|
||||
"settings.permissions.ownerNotice": "会社のオーナーは常にフルアクセスを持ち、制限できません。",
|
||||
"settings.permissions.ownerOnly": "権限を変更できるのは会社のオーナーだけです。",
|
||||
"settings.permissions.summary": "{total} 件中 {allowed} 件のエリアを許可",
|
||||
"settings.permissions.allowAll": "すべて許可",
|
||||
"settings.permissions.denyAll": "すべて拒否",
|
||||
"settings.permissions.planLocked": "現在のプランに含まれません",
|
||||
"settings.permissions.searchPlaceholder": "エリアを検索…",
|
||||
"settings.permissions.noMatches": "検索に一致するエリアはありません。",
|
||||
"settings.permissions.save": "権限を保存",
|
||||
"settings.permissions.saved": "{email} の権限を更新しました",
|
||||
"settings.permissions.saveFailed": "権限を更新できませんでした",
|
||||
"settings.permissions.loadFailed": "権限を読み込めませんでした",
|
||||
"settings.permissions.restrictedBadge": "制限あり",
|
||||
"settings.permissions.restrictedHint": "{count} 件のエリアを無効化",
|
||||
"settings.tabLockedByAdmin": "会社のオーナーが無効にしています"
|
||||
};
|
||||
|
||||
@@ -5667,4 +5667,25 @@ export const nl: MessageDict = {
|
||||
"files.empty": "Nog geen uploads.",
|
||||
"products.filters.sortProducts": "Producten sorteren",
|
||||
"products.table.options": "Opties",
|
||||
"access.restricted.title": "Toegang beperkt",
|
||||
"access.restricted.message": "De eigenaar van je bedrijf heeft toegang tot dit onderdeel uitgeschakeld. Vraag of het weer aangezet kan worden als je het nodig hebt.",
|
||||
"access.restricted.featureHint": "Rechten: {feature}",
|
||||
"settings.permissions.action": "Rechten",
|
||||
"settings.permissions.title": "Rechten voor {email}",
|
||||
"settings.permissions.description": "Bepaal wat dit teamlid mag openen. Een uitgeschakeld onderdeel verdwijnt uit de zijbalk en wordt geblokkeerd in de API.",
|
||||
"settings.permissions.ownerNotice": "De bedrijfseigenaar heeft altijd volledige toegang en kan niet worden beperkt.",
|
||||
"settings.permissions.ownerOnly": "Alleen de bedrijfseigenaar kan rechten wijzigen.",
|
||||
"settings.permissions.summary": "{allowed} van {total} onderdelen toegestaan",
|
||||
"settings.permissions.allowAll": "Alles toestaan",
|
||||
"settings.permissions.denyAll": "Alles blokkeren",
|
||||
"settings.permissions.planLocked": "Niet in je abonnement",
|
||||
"settings.permissions.searchPlaceholder": "Onderdelen zoeken…",
|
||||
"settings.permissions.noMatches": "Geen onderdelen gevonden voor deze zoekopdracht.",
|
||||
"settings.permissions.save": "Rechten opslaan",
|
||||
"settings.permissions.saved": "Rechten bijgewerkt voor {email}",
|
||||
"settings.permissions.saveFailed": "Rechten konden niet worden bijgewerkt",
|
||||
"settings.permissions.loadFailed": "Rechten konden niet worden geladen",
|
||||
"settings.permissions.restrictedBadge": "Beperkt",
|
||||
"settings.permissions.restrictedHint": "{count} onderdelen uitgeschakeld",
|
||||
"settings.tabLockedByAdmin": "Uitgeschakeld door de bedrijfseigenaar"
|
||||
};
|
||||
|
||||
@@ -5667,4 +5667,25 @@ export const pl: MessageDict = {
|
||||
"files.empty": "Brak przesłań.",
|
||||
"products.filters.sortProducts": "Sortuj produkty",
|
||||
"products.table.options": "Opcje",
|
||||
"access.restricted.title": "Dostęp ograniczony",
|
||||
"access.restricted.message": "Właściciel firmy wyłączył dostęp do tej sekcji. Poproś go o włączenie, jeśli jej potrzebujesz.",
|
||||
"access.restricted.featureHint": "Uprawnienie: {feature}",
|
||||
"settings.permissions.action": "Uprawnienia",
|
||||
"settings.permissions.title": "Uprawnienia dla {email}",
|
||||
"settings.permissions.description": "Wybierz, co może otwierać ten członek zespołu. Wyłączona sekcja znika z jego menu bocznego i jest blokowana w API.",
|
||||
"settings.permissions.ownerNotice": "Właściciel firmy zawsze ma pełny dostęp i nie można go ograniczać.",
|
||||
"settings.permissions.ownerOnly": "Tylko właściciel firmy może zmieniać uprawnienia.",
|
||||
"settings.permissions.summary": "Dozwolone {allowed} z {total} sekcji",
|
||||
"settings.permissions.allowAll": "Zezwól na wszystko",
|
||||
"settings.permissions.denyAll": "Zablokuj wszystko",
|
||||
"settings.permissions.planLocked": "Niedostępne w Twoim planie",
|
||||
"settings.permissions.searchPlaceholder": "Szukaj sekcji…",
|
||||
"settings.permissions.noMatches": "Brak sekcji pasujących do wyszukiwania.",
|
||||
"settings.permissions.save": "Zapisz uprawnienia",
|
||||
"settings.permissions.saved": "Zaktualizowano uprawnienia dla {email}",
|
||||
"settings.permissions.saveFailed": "Nie udało się zaktualizować uprawnień",
|
||||
"settings.permissions.loadFailed": "Nie udało się wczytać uprawnień",
|
||||
"settings.permissions.restrictedBadge": "Ograniczony",
|
||||
"settings.permissions.restrictedHint": "Wyłączonych sekcji: {count}",
|
||||
"settings.tabLockedByAdmin": "Wyłączone przez właściciela firmy"
|
||||
};
|
||||
|
||||
@@ -5667,4 +5667,25 @@ export const pt: MessageDict = {
|
||||
"files.empty": "Ainda sem carregamentos.",
|
||||
"products.filters.sortProducts": "Ordenar produtos",
|
||||
"products.table.options": "Opções",
|
||||
"access.restricted.title": "Acesso restrito",
|
||||
"access.restricted.message": "O proprietário da sua empresa desativou o acesso a esta área. Peça-lhe para a ativar se precisar dela.",
|
||||
"access.restricted.featureHint": "Permissão: {feature}",
|
||||
"settings.permissions.action": "Permissões",
|
||||
"settings.permissions.title": "Permissões de {email}",
|
||||
"settings.permissions.description": "Escolha o que este membro pode abrir. Desativar uma área oculta-a da barra lateral e bloqueia-a na API.",
|
||||
"settings.permissions.ownerNotice": "O proprietário da empresa tem sempre acesso total e não pode ser restringido.",
|
||||
"settings.permissions.ownerOnly": "Só o proprietário da empresa pode alterar permissões.",
|
||||
"settings.permissions.summary": "{allowed} de {total} áreas permitidas",
|
||||
"settings.permissions.allowAll": "Permitir tudo",
|
||||
"settings.permissions.denyAll": "Negar tudo",
|
||||
"settings.permissions.planLocked": "Não incluído no seu plano",
|
||||
"settings.permissions.searchPlaceholder": "Procurar áreas…",
|
||||
"settings.permissions.noMatches": "Nenhuma área corresponde a essa procura.",
|
||||
"settings.permissions.save": "Guardar permissões",
|
||||
"settings.permissions.saved": "Permissões atualizadas para {email}",
|
||||
"settings.permissions.saveFailed": "Não foi possível atualizar as permissões",
|
||||
"settings.permissions.loadFailed": "Não foi possível carregar as permissões",
|
||||
"settings.permissions.restrictedBadge": "Restrito",
|
||||
"settings.permissions.restrictedHint": "{count} áreas desativadas",
|
||||
"settings.tabLockedByAdmin": "Desativado pelo proprietário da sua empresa"
|
||||
};
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import {
|
||||
catalogKeys,
|
||||
denyAll,
|
||||
featureAncestors,
|
||||
filterCatalog,
|
||||
isDenied,
|
||||
permissionSummary,
|
||||
samePermissions,
|
||||
setPermissionAllowed,
|
||||
type PermissionCatalog
|
||||
} from "./member-permissions.ts";
|
||||
|
||||
const catalog: PermissionCatalog = {
|
||||
sections: [
|
||||
{
|
||||
id: "catalog",
|
||||
entries: [
|
||||
{ key: "catalog.products", section: "catalog", parent: "", plan_allowed: true },
|
||||
{
|
||||
key: "catalog.products.tab_error",
|
||||
section: "catalog",
|
||||
parent: "catalog.products",
|
||||
plan_allowed: true
|
||||
},
|
||||
{
|
||||
key: "catalog.products.export_selection",
|
||||
section: "catalog",
|
||||
parent: "catalog.products",
|
||||
plan_allowed: true
|
||||
},
|
||||
{ key: "catalog.categories", section: "catalog", parent: "", plan_allowed: true }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "stores",
|
||||
entries: [
|
||||
{ key: "stores.hub", section: "stores", parent: "", plan_allowed: false },
|
||||
{ key: "stores.shopify", section: "stores", parent: "", plan_allowed: false }
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const ALL = catalogKeys(catalog);
|
||||
|
||||
describe("featureAncestors", () => {
|
||||
it("lists progressively shorter prefixes", () => {
|
||||
assert.deepEqual(featureAncestors("catalog.products.tab_error"), [
|
||||
"catalog",
|
||||
"catalog.products"
|
||||
]);
|
||||
assert.deepEqual(featureAncestors("stores"), []);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isDenied", () => {
|
||||
it("honours parent-prefix denial", () => {
|
||||
const denied = ["catalog.products"];
|
||||
assert.equal(isDenied(denied, "catalog.products"), true);
|
||||
assert.equal(isDenied(denied, "catalog.products.tab_error"), true);
|
||||
assert.equal(isDenied(denied, "catalog.categories"), false);
|
||||
assert.equal(isDenied([], "catalog.products"), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setPermissionAllowed", () => {
|
||||
it("denying a parent drops redundant child denials", () => {
|
||||
const got = setPermissionAllowed(
|
||||
["catalog.products.tab_error"],
|
||||
"catalog.products",
|
||||
false,
|
||||
ALL
|
||||
);
|
||||
assert.deepEqual(got, ["catalog.products"]);
|
||||
});
|
||||
|
||||
it("allowing a key lifts the denied ancestor and re-denies its other branches", () => {
|
||||
const got = setPermissionAllowed(
|
||||
["catalog.products"],
|
||||
"catalog.products.tab_error",
|
||||
true,
|
||||
ALL
|
||||
);
|
||||
// tab_error opens; export_selection must NOT silently open with it.
|
||||
assert.deepEqual(got, ["catalog.products.export_selection"]);
|
||||
assert.equal(isDenied(got, "catalog.products.tab_error"), false);
|
||||
assert.equal(isDenied(got, "catalog.products.export_selection"), true);
|
||||
assert.equal(isDenied(got, "catalog.products"), false);
|
||||
});
|
||||
|
||||
it("allowing an already-allowed key is a no-op", () => {
|
||||
assert.deepEqual(setPermissionAllowed(["stores.hub"], "catalog.products", true, ALL), [
|
||||
"stores.hub"
|
||||
]);
|
||||
});
|
||||
|
||||
it("denying then allowing the same key round-trips", () => {
|
||||
const denied = setPermissionAllowed([], "catalog.categories", false, ALL);
|
||||
assert.deepEqual(denied, ["catalog.categories"]);
|
||||
assert.deepEqual(setPermissionAllowed(denied, "catalog.categories", true, ALL), []);
|
||||
});
|
||||
|
||||
it("drops keys that are not in the catalog", () => {
|
||||
const got = setPermissionAllowed(["gone.key"], "catalog.categories", false, ALL);
|
||||
assert.deepEqual(got, ["catalog.categories"]);
|
||||
});
|
||||
|
||||
it("returns a sorted list so equality checks are stable", () => {
|
||||
const got = setPermissionAllowed(["stores.hub"], "catalog.categories", false, ALL);
|
||||
assert.deepEqual(got, ["catalog.categories", "stores.hub"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("denyAll", () => {
|
||||
it("collapses to the top-level keys only", () => {
|
||||
assert.deepEqual(denyAll(catalog), [
|
||||
"catalog.categories",
|
||||
"catalog.products",
|
||||
"stores.hub",
|
||||
"stores.shopify"
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("permissionSummary", () => {
|
||||
it("counts only keys the plan already allows", () => {
|
||||
// stores.* are plan_allowed: false, so they are outside the total.
|
||||
assert.deepEqual(permissionSummary(catalog, []), { allowed: 4, total: 4 });
|
||||
assert.deepEqual(permissionSummary(catalog, ["catalog.products"]), { allowed: 1, total: 4 });
|
||||
assert.deepEqual(permissionSummary(null, []), { allowed: 0, total: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("samePermissions", () => {
|
||||
it("ignores order", () => {
|
||||
assert.equal(samePermissions(["a", "b"], ["b", "a"]), true);
|
||||
assert.equal(samePermissions(["a"], ["a", "b"]), false);
|
||||
assert.equal(samePermissions([], []), true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("filterCatalog", () => {
|
||||
const labelFor = (key: string) => (key === "catalog.categories" ? "Categories" : key);
|
||||
|
||||
it("returns everything for an empty query", () => {
|
||||
assert.equal(filterCatalog(catalog, " ", labelFor).length, 2);
|
||||
});
|
||||
|
||||
it("matches on key and on label, dropping empty sections", () => {
|
||||
const byKey = filterCatalog(catalog, "shopify", labelFor);
|
||||
assert.deepEqual(
|
||||
byKey.map((s) => s.id),
|
||||
["stores"]
|
||||
);
|
||||
const byLabel = filterCatalog(catalog, "categor", labelFor);
|
||||
assert.deepEqual(
|
||||
byLabel.flatMap((s) => s.entries.map((e) => e.key)),
|
||||
["catalog.categories"]
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* Per-member access permissions (settings > team).
|
||||
*
|
||||
* The company owner stores a sparse list of DENIED feature keys per member; everything
|
||||
* else is inherited (allowed, still bounded by the plan). Denying a parent key denies
|
||||
* its descendants, mirroring billing.MemberDeniesFeature in
|
||||
* apps/api/internal/billing/member_permissions.go.
|
||||
*
|
||||
* Kept free of $lib/i18n and $app imports so node:test can exercise it directly —
|
||||
* labels come from $lib/plan-feature-catalog at render time.
|
||||
*/
|
||||
|
||||
/** One grantable feature key as returned by GET /api/team/permission-catalog. */
|
||||
export type PermissionCatalogEntry = {
|
||||
key: string;
|
||||
section: string;
|
||||
/** Nearest grantable ancestor key, or "" for a top-level area. */
|
||||
parent?: string;
|
||||
/** False when the company's own plan already denies the key (nothing to grant). */
|
||||
plan_allowed: boolean;
|
||||
};
|
||||
|
||||
export type PermissionCatalogSection = {
|
||||
id: string;
|
||||
entries: PermissionCatalogEntry[];
|
||||
};
|
||||
|
||||
export type PermissionCatalog = {
|
||||
sections: PermissionCatalogSection[];
|
||||
};
|
||||
|
||||
/** GET/PUT /api/team/{userID}/permissions. */
|
||||
export type MemberPermissionsView = {
|
||||
user_id: string;
|
||||
role: string;
|
||||
is_owner: boolean;
|
||||
denied: string[];
|
||||
restricted: boolean;
|
||||
};
|
||||
|
||||
/** Ancestor keys of "a.b.c" → ["a", "a.b"]. */
|
||||
export function featureAncestors(key: string): string[] {
|
||||
const parts = key.split(".");
|
||||
const out: string[] = [];
|
||||
for (let i = 1; i < parts.length; i++) out.push(parts.slice(0, i).join("."));
|
||||
return out;
|
||||
}
|
||||
|
||||
/** True when key, or any ancestor of it, is in the denied set. */
|
||||
export function isDenied(denied: Iterable<string>, key: string): boolean {
|
||||
const set = denied instanceof Set ? denied : new Set(denied);
|
||||
if (set.size === 0) return false;
|
||||
if (set.has(key)) return true;
|
||||
return featureAncestors(key).some((parent) => set.has(parent));
|
||||
}
|
||||
|
||||
/** Every catalog key, flattened in section order. */
|
||||
export function catalogKeys(catalog: PermissionCatalog | null | undefined): string[] {
|
||||
if (!catalog?.sections) return [];
|
||||
return catalog.sections.flatMap((section) => section.entries.map((entry) => entry.key));
|
||||
}
|
||||
|
||||
function descendantsOf(key: string, allKeys: string[]): string[] {
|
||||
return allKeys.filter((candidate) => candidate.startsWith(`${key}.`));
|
||||
}
|
||||
|
||||
/** Direct children of key within allKeys (no grandchildren). */
|
||||
function childrenOf(key: string, allKeys: string[]): string[] {
|
||||
const depth = key.split(".").length;
|
||||
return allKeys.filter(
|
||||
(candidate) => candidate.startsWith(`${key}.`) && candidate.split(".").length === depth + 1
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle one key and return the normalized denied list.
|
||||
*
|
||||
* Normalization keeps the stored overlay minimal and unambiguous:
|
||||
* - denying a key drops its now-redundant descendants,
|
||||
* - allowing a key whose ancestor is denied lifts that ancestor and pushes the denial
|
||||
* down onto the ancestor's other branches, so no other area silently re-opens.
|
||||
*/
|
||||
export function setPermissionAllowed(
|
||||
denied: Iterable<string>,
|
||||
key: string,
|
||||
allowed: boolean,
|
||||
allKeys: string[]
|
||||
): string[] {
|
||||
const next = new Set(denied);
|
||||
|
||||
if (!allowed) {
|
||||
for (const descendant of descendantsOf(key, allKeys)) next.delete(descendant);
|
||||
next.add(key);
|
||||
return normalize(next, allKeys);
|
||||
}
|
||||
|
||||
next.delete(key);
|
||||
// Lift every denied ancestor, re-denying its other branches so only `key` opens up.
|
||||
for (const ancestor of featureAncestors(key)) {
|
||||
if (!next.has(ancestor)) continue;
|
||||
next.delete(ancestor);
|
||||
const openPath = new Set([key, ...featureAncestors(key)]);
|
||||
for (const sibling of childrenOf(ancestor, allKeys)) {
|
||||
if (!openPath.has(sibling)) next.add(sibling);
|
||||
}
|
||||
}
|
||||
return normalize(next, allKeys);
|
||||
}
|
||||
|
||||
/** Drop keys outside the catalog and denials already implied by a denied ancestor. */
|
||||
function normalize(denied: Set<string>, allKeys: string[]): string[] {
|
||||
const known = new Set(allKeys);
|
||||
const out: string[] = [];
|
||||
for (const key of denied) {
|
||||
if (!known.has(key)) continue;
|
||||
if (featureAncestors(key).some((parent) => denied.has(parent))) continue;
|
||||
out.push(key);
|
||||
}
|
||||
return out.sort();
|
||||
}
|
||||
|
||||
/** Deny every grantable key (the always-on shell keys are not in the catalog). */
|
||||
export function denyAll(catalog: PermissionCatalog | null | undefined): string[] {
|
||||
const all = catalogKeys(catalog);
|
||||
return normalize(new Set(all), all);
|
||||
}
|
||||
|
||||
/** Allowed / total counts for the summary line, ignoring keys the plan already denies. */
|
||||
export function permissionSummary(
|
||||
catalog: PermissionCatalog | null | undefined,
|
||||
denied: Iterable<string>
|
||||
): { allowed: number; total: number } {
|
||||
const set = new Set(denied);
|
||||
let allowed = 0;
|
||||
let total = 0;
|
||||
for (const section of catalog?.sections ?? []) {
|
||||
for (const entry of section.entries) {
|
||||
if (!entry.plan_allowed) continue;
|
||||
total += 1;
|
||||
if (!isDenied(set, entry.key)) allowed += 1;
|
||||
}
|
||||
}
|
||||
return { allowed, total };
|
||||
}
|
||||
|
||||
/** True when two denied lists describe the same overlay (order-insensitive). */
|
||||
export function samePermissions(a: string[], b: string[]): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
const set = new Set(a);
|
||||
return b.every((key) => set.has(key));
|
||||
}
|
||||
|
||||
/** Filter a catalog to entries matching a free-text query over key and label. */
|
||||
export function filterCatalog(
|
||||
catalog: PermissionCatalog | null | undefined,
|
||||
query: string,
|
||||
labelFor: (key: string) => string
|
||||
): PermissionCatalogSection[] {
|
||||
const needle = query.trim().toLowerCase();
|
||||
const sections = catalog?.sections ?? [];
|
||||
if (!needle) return sections;
|
||||
const out: PermissionCatalogSection[] = [];
|
||||
for (const section of sections) {
|
||||
const entries = section.entries.filter(
|
||||
(entry) =>
|
||||
entry.key.toLowerCase().includes(needle) ||
|
||||
labelFor(entry.key).toLowerCase().includes(needle)
|
||||
);
|
||||
if (entries.length > 0) out.push({ ...section, entries });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -14,6 +14,8 @@ let sectionsState = $state<Record<string, boolean> | null>(null);
|
||||
let statusState = $state<CapabilitiesStatus>("idle");
|
||||
let etagState = $state<string | null>(null);
|
||||
let planNameState = $state<string | null>(null);
|
||||
/** Feature keys the plan allows but the company owner denied for this member. */
|
||||
let memberDeniedState = $state<Set<string>>(new Set());
|
||||
let fetchedOnce = false;
|
||||
/** Soft TTL so admin gate/plan edits show up without a full reload. */
|
||||
let fetchedAtMs = 0;
|
||||
@@ -31,6 +33,13 @@ function applyCredits(credits?: CreditsLike | null) {
|
||||
if (sections && typeof sections === "object" && !Array.isArray(sections)) {
|
||||
sectionsState = sections;
|
||||
}
|
||||
applyMemberDenied(credits?.member_denied_features);
|
||||
}
|
||||
|
||||
/** Track owner-set per-member denials so gates can say "ask your admin", not "upgrade". */
|
||||
function applyMemberDenied(denied?: string[] | null) {
|
||||
if (!Array.isArray(denied)) return;
|
||||
memberDeniedState = new Set(denied);
|
||||
}
|
||||
|
||||
function applyCapabilities(payload: CapabilitiesResponse) {
|
||||
@@ -49,6 +58,7 @@ function applyCapabilities(payload: CapabilitiesResponse) {
|
||||
if (typeof payload.plan_name === "string" && payload.plan_name.trim()) {
|
||||
planNameState = payload.plan_name.trim();
|
||||
}
|
||||
applyMemberDenied(payload.member_denied_features);
|
||||
}
|
||||
|
||||
/** Shared plan capabilities snapshot — fetch once per dashboard session. */
|
||||
@@ -68,6 +78,23 @@ export const planCapabilities = {
|
||||
get planName(): string | null {
|
||||
return planNameState;
|
||||
},
|
||||
/** True when the company owner has narrowed this member's access at all. */
|
||||
get memberRestricted(): boolean {
|
||||
return memberDeniedState.size > 0;
|
||||
},
|
||||
/**
|
||||
* True when THIS key is off because the company owner turned it off, rather than
|
||||
* because the plan lacks it. Honours parent-prefix denial, matching the API.
|
||||
*/
|
||||
memberDenied(key: string): boolean {
|
||||
if (memberDeniedState.size === 0) return false;
|
||||
if (memberDeniedState.has(key)) return true;
|
||||
const parts = key.split(".");
|
||||
for (let i = 1; i < parts.length; i++) {
|
||||
if (memberDeniedState.has(parts.slice(0, i).join("."))) return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
/** Seed from /api/auth/me credits (preferred primary source per contract). */
|
||||
hydrateFromCredits(credits?: CreditsLike | null) {
|
||||
applyCredits(credits);
|
||||
@@ -78,6 +105,7 @@ export const planCapabilities = {
|
||||
statusState = "idle";
|
||||
etagState = null;
|
||||
planNameState = null;
|
||||
memberDeniedState = new Set();
|
||||
fetchedOnce = false;
|
||||
fetchedAtMs = 0;
|
||||
},
|
||||
|
||||
@@ -18,6 +18,10 @@ export type CapabilitiesResponse = {
|
||||
sections?: Record<string, boolean>;
|
||||
disabled_features?: string[];
|
||||
feature_etag?: string;
|
||||
/** True when the company owner narrowed this member's access (see settings > team). */
|
||||
member_restricted?: boolean;
|
||||
/** Keys the plan allows but the member overlay denies — "ask your admin", not "upgrade". */
|
||||
member_denied_features?: string[];
|
||||
entitlements?: {
|
||||
can_use_ai?: boolean;
|
||||
can_use_eprel?: boolean;
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import { coerceImageUrl, coerceImageUrlList, productImageUrls } from "./product-images.ts";
|
||||
|
||||
describe("coerceImageUrl", () => {
|
||||
it("keeps absolute http(s) URLs and upgrades protocol-relative ones", () => {
|
||||
assert.equal(coerceImageUrl("https://cdn.example/a.jpg"), "https://cdn.example/a.jpg");
|
||||
assert.equal(coerceImageUrl(" http://cdn.example/a.jpg "), "http://cdn.example/a.jpg");
|
||||
assert.equal(coerceImageUrl("//cdn.example/a.jpg"), "https://cdn.example/a.jpg");
|
||||
});
|
||||
|
||||
it("rejects empties, relative paths and the [object Object] sentinel", () => {
|
||||
assert.equal(coerceImageUrl(""), "");
|
||||
assert.equal(coerceImageUrl(" "), "");
|
||||
assert.equal(coerceImageUrl("/media/a.jpg"), "");
|
||||
assert.equal(coerceImageUrl("[object Object]"), "");
|
||||
assert.equal(coerceImageUrl(null), "");
|
||||
assert.equal(coerceImageUrl(42), "");
|
||||
});
|
||||
|
||||
it("unwraps XML/JSON node objects and arrays", () => {
|
||||
assert.equal(coerceImageUrl({ "#text": "https://cdn.example/a.jpg" }), "https://cdn.example/a.jpg");
|
||||
assert.equal(coerceImageUrl({ "@_url": "https://cdn.example/b.jpg" }), "https://cdn.example/b.jpg");
|
||||
assert.equal(coerceImageUrl({ src: "https://cdn.example/c.jpg" }), "https://cdn.example/c.jpg");
|
||||
assert.equal(coerceImageUrl(["", "https://cdn.example/d.jpg"]), "https://cdn.example/d.jpg");
|
||||
});
|
||||
});
|
||||
|
||||
describe("coerceImageUrlList", () => {
|
||||
it("splits the comma-separated form supplier feeds use for moreimages", () => {
|
||||
assert.deepEqual(
|
||||
coerceImageUrlList("https://cdn.example/1.jpg,https://cdn.example/2.jpg"),
|
||||
["https://cdn.example/1.jpg", "https://cdn.example/2.jpg"]
|
||||
);
|
||||
});
|
||||
|
||||
it("handles single values, arrays and junk entries", () => {
|
||||
assert.deepEqual(coerceImageUrlList("https://cdn.example/1.jpg"), ["https://cdn.example/1.jpg"]);
|
||||
assert.deepEqual(coerceImageUrlList(["https://cdn.example/1.jpg", "nope", ""]), [
|
||||
"https://cdn.example/1.jpg"
|
||||
]);
|
||||
assert.deepEqual(coerceImageUrlList(""), []);
|
||||
assert.deepEqual(coerceImageUrlList(null), []);
|
||||
});
|
||||
});
|
||||
|
||||
describe("productImageUrls", () => {
|
||||
it("returns main_image first, then the comma-joined moreimages list", () => {
|
||||
const product = {
|
||||
mapped_data: {
|
||||
main_image: "https://cdn.example/105180.jpg",
|
||||
moreimages: "https://cdn.example/105180_1.jpg,https://cdn.example/105180_2.jpg"
|
||||
}
|
||||
};
|
||||
assert.deepEqual(productImageUrls(product), [
|
||||
"https://cdn.example/105180.jpg",
|
||||
"https://cdn.example/105180_1.jpg",
|
||||
"https://cdn.example/105180_2.jpg"
|
||||
]);
|
||||
});
|
||||
|
||||
it("falls back to additional_image_urls when main_image is an empty string", () => {
|
||||
const product = {
|
||||
mapped_data: {
|
||||
main_image: "",
|
||||
additional_image_urls: "https://cdn.example/a.jpg,https://cdn.example/b.jpg"
|
||||
}
|
||||
};
|
||||
assert.deepEqual(productImageUrls(product), [
|
||||
"https://cdn.example/a.jpg",
|
||||
"https://cdn.example/b.jpg"
|
||||
]);
|
||||
});
|
||||
|
||||
it("reads the v1 detail DTO shape from the row itself", () => {
|
||||
const product = {
|
||||
main_image: "https://cdn.example/main.jpg",
|
||||
more_images: ["https://cdn.example/more1.jpg", "https://cdn.example/more2.jpg"]
|
||||
};
|
||||
assert.deepEqual(productImageUrls(product), [
|
||||
"https://cdn.example/main.jpg",
|
||||
"https://cdn.example/more1.jpg",
|
||||
"https://cdn.example/more2.jpg"
|
||||
]);
|
||||
});
|
||||
|
||||
it("dedupes across bags and keys", () => {
|
||||
const product = {
|
||||
mapped_data: {
|
||||
image_url: "https://cdn.example/a.jpg",
|
||||
main_image: "https://cdn.example/a.jpg",
|
||||
images: ["https://cdn.example/a.jpg", "https://cdn.example/b.jpg"]
|
||||
},
|
||||
main_image: "https://cdn.example/a.jpg"
|
||||
};
|
||||
assert.deepEqual(productImageUrls(product), [
|
||||
"https://cdn.example/a.jpg",
|
||||
"https://cdn.example/b.jpg"
|
||||
]);
|
||||
});
|
||||
|
||||
it("is empty for products with no usable image values", () => {
|
||||
assert.deepEqual(productImageUrls(null), []);
|
||||
assert.deepEqual(productImageUrls({}), []);
|
||||
assert.deepEqual(productImageUrls({ mapped_data: { main_image: "" } }), []);
|
||||
assert.deepEqual(productImageUrls({ mapped_data: "not-an-object" }), []);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Product image extraction for the dashboard.
|
||||
*
|
||||
* Mirrors `catalog.ExtractProductImages` (apps/api/internal/catalog/raw_v1.go) key-for-key.
|
||||
* Supplier feeds are inconsistent: `main_image` is frequently empty while `moreimages`
|
||||
* (no underscore) or `additional_image_urls` carries a comma-separated list, so any
|
||||
* divergence from the Go key sets makes the product editor render nothing.
|
||||
*/
|
||||
|
||||
/** Minimal shape needed here — the full row type lives in components/products/types.ts. */
|
||||
export type ProductImageSource = Record<string, unknown> | null | undefined;
|
||||
|
||||
/** Main-image keys, in precedence order (matches Go mainKeys + the legacy `image` alias). */
|
||||
export const MAIN_IMAGE_KEYS = [
|
||||
"image_url",
|
||||
"main_image",
|
||||
"image_link",
|
||||
"mainImage",
|
||||
"MainImage",
|
||||
"imageUrl",
|
||||
"imageLink",
|
||||
"ImageLink",
|
||||
"image"
|
||||
] as const;
|
||||
|
||||
/** Additional-image keys (matches Go moreKeys + the legacy `additional_images` alias). */
|
||||
export const MORE_IMAGE_KEYS = [
|
||||
"additional_image_urls",
|
||||
"additional_image_link",
|
||||
"more_images",
|
||||
"moreImages",
|
||||
"MoreImages",
|
||||
"moreimages",
|
||||
"additionalImageLink",
|
||||
"additionalImageUrls",
|
||||
"additional_images"
|
||||
] as const;
|
||||
|
||||
/** Keys used by XML/JSON feeds that wrap a URL inside an object node. */
|
||||
const NESTED_URL_KEYS = [
|
||||
"#text",
|
||||
"__cdata",
|
||||
"@_href",
|
||||
"@_url",
|
||||
"href",
|
||||
"url",
|
||||
"src",
|
||||
"link",
|
||||
"image"
|
||||
];
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Normalize one value to an absolute http(s) URL, or "" (Go: coerceToURLString). */
|
||||
export function coerceImageUrl(value: unknown): string {
|
||||
if (value == null) return "";
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed === "" || trimmed === "[object Object]") return "";
|
||||
// Protocol-relative CDN URLs (//cdn.example/x.jpg) are common in supplier feeds.
|
||||
if (trimmed.startsWith("//")) return `https:${trimmed}`;
|
||||
return /^https?:\/\//i.test(trimmed) ? trimmed : "";
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
for (const entry of value) {
|
||||
const url = coerceImageUrl(entry);
|
||||
if (url) return url;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
const rec = asRecord(value);
|
||||
if (!rec) return "";
|
||||
for (const key of NESTED_URL_KEYS) {
|
||||
const url = coerceImageUrl(rec[key]);
|
||||
if (url) return url;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/** Normalize one value to a URL list, splitting comma-joined feed strings (Go: coerceToURLList). */
|
||||
export function coerceImageUrlList(value: unknown): string[] {
|
||||
if (value == null) return [];
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed === "") return [];
|
||||
if (trimmed.includes(",")) {
|
||||
return trimmed
|
||||
.split(",")
|
||||
.map((part) => coerceImageUrl(part))
|
||||
.filter((url) => url !== "");
|
||||
}
|
||||
const url = coerceImageUrl(trimmed);
|
||||
return url ? [url] : [];
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((entry) => coerceImageUrl(entry)).filter((url) => url !== "");
|
||||
}
|
||||
const url = coerceImageUrl(value);
|
||||
return url ? [url] : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect http(s) product image URLs in display order (main image first, deduped).
|
||||
*
|
||||
* Bags are scanned in precedence order: `mapped_data` (the feed payload), the product row
|
||||
* itself (the v1 detail DTO exposes top-level main_image / more_images), then the
|
||||
* processed / raw attribute bags.
|
||||
*/
|
||||
export function productImageUrls(product: ProductImageSource): string[] {
|
||||
const row = asRecord(product);
|
||||
if (!row) return [];
|
||||
const bags = [
|
||||
asRecord(row.mapped_data),
|
||||
row,
|
||||
asRecord(row.processed_attributes),
|
||||
asRecord(row.attributes)
|
||||
].filter((bag): bag is Record<string, unknown> => bag != null);
|
||||
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
const add = (url: string) => {
|
||||
if (!url || seen.has(url)) return;
|
||||
seen.add(url);
|
||||
out.push(url);
|
||||
};
|
||||
|
||||
for (const bag of bags) {
|
||||
for (const key of MAIN_IMAGE_KEYS) {
|
||||
if (bag[key] != null) add(coerceImageUrl(bag[key]));
|
||||
}
|
||||
for (const key of MORE_IMAGE_KEYS) {
|
||||
if (bag[key] != null) coerceImageUrlList(bag[key]).forEach(add);
|
||||
}
|
||||
// `images` may hold the main image plus the rest as a single list.
|
||||
if (bag.images != null) coerceImageUrlList(bag.images).forEach(add);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -76,7 +76,11 @@ export function contentSecurityPolicy(opts: ContentSecurityPolicyOptions): strin
|
||||
connect.push(opts.apiOrigin);
|
||||
}
|
||||
const fontSrc = "font-src 'self' data:";
|
||||
const imgSrc = joinSrc("img-src 'self' data: blob:", gtmImg);
|
||||
// https: — product images come from arbitrary supplier CDNs resolved at feed-sync
|
||||
// time (mapped_data.main_image / moreimages), so the host set cannot be allowlisted.
|
||||
// Images only: no script/style/connect relaxation, and referrerpolicy="no-referrer"
|
||||
// on the <img> tags keeps dashboard URLs out of supplier logs.
|
||||
const imgSrc = joinSrc("img-src 'self' data: blob: https:", gtmImg);
|
||||
const frameSrc = joinSrc("frame-src 'self'", gtmFrame);
|
||||
|
||||
if (opts.dev) {
|
||||
|
||||
@@ -238,6 +238,10 @@ export type TeamMember = {
|
||||
status?: string | null;
|
||||
created_at?: string | null;
|
||||
is_owner?: boolean;
|
||||
/** True when the company owner narrowed this member's dashboard access. */
|
||||
restricted?: boolean;
|
||||
/** How many areas are turned off for this member (0 = full plan access). */
|
||||
denied_count?: number;
|
||||
};
|
||||
|
||||
export type ChannelSyncSummary = {
|
||||
|
||||
@@ -64,6 +64,7 @@
|
||||
} from "$lib/types";
|
||||
import PageShell from "$lib/components/PageShell.svelte";
|
||||
import Alert from "$lib/components/Alert.svelte";
|
||||
import MemberPermissionsDialog from "$lib/components/settings/MemberPermissionsDialog.svelte";
|
||||
import Spinner from "$lib/components/Spinner.svelte";
|
||||
import EmptyState from "$lib/components/EmptyState.svelte";
|
||||
import ForbiddenEmptyState from "$lib/components/ForbiddenEmptyState.svelte";
|
||||
@@ -113,6 +114,18 @@
|
||||
return settingsTabAllowed(tab, (key) => planCapabilities.can(key));
|
||||
}
|
||||
|
||||
/**
|
||||
* Why a tab is locked: an owner-set member restriction reads differently from a plan
|
||||
* limit — "upgrade" would be misleading advice when only an admin can re-enable it.
|
||||
*/
|
||||
function tabLockedHint(tab: Tab): string {
|
||||
const key = SETTINGS_TAB_FEATURES[tab];
|
||||
if (key && planCapabilities.memberDenied(key)) {
|
||||
return i18n.t("settings.tabLockedByAdmin");
|
||||
}
|
||||
return i18n.t("settings.tabLockedHint");
|
||||
}
|
||||
|
||||
function firstAllowedTab(): Tab {
|
||||
return TAB_ORDER.find((t) => tabAllowed(t)) ?? "profile";
|
||||
}
|
||||
@@ -187,6 +200,9 @@
|
||||
let pendingInvites = $state<PendingInvite[]>([]);
|
||||
let canAdmin = $state(false);
|
||||
let canOwner = $state(false);
|
||||
/** Per-member access overlay editor (owner-only edits; admins get a read-only view). */
|
||||
let permissionsOpen = $state(false);
|
||||
let permissionsMember = $state<TeamMember | null>(null);
|
||||
let accessDenied = $state(false);
|
||||
let teamForbidden = $state(false);
|
||||
let apiKeysForbidden = $state(false);
|
||||
@@ -843,6 +859,24 @@
|
||||
return (member.user_id ?? member.id) === user.id;
|
||||
}
|
||||
|
||||
function openPermissions(member: TeamMember) {
|
||||
permissionsMember = member;
|
||||
permissionsOpen = true;
|
||||
}
|
||||
|
||||
/** Reflect the saved overlay in the team table without refetching the whole list. */
|
||||
function applyPermissionsSaved(view: { user_id: string; denied: string[] }) {
|
||||
const count = view.denied?.length ?? 0;
|
||||
team = team.map((m) =>
|
||||
(m.user_id ?? m.id) === view.user_id
|
||||
? { ...m, restricted: count > 0, denied_count: count }
|
||||
: m
|
||||
);
|
||||
success = i18n.t("settings.permissions.saved", {
|
||||
email: team.find((m) => (m.user_id ?? m.id) === view.user_id)?.email ?? ""
|
||||
});
|
||||
}
|
||||
|
||||
function roleLabel(role?: string | null): string {
|
||||
return role === "admin" ? i18n.t("settings.role.admin") : i18n.t("settings.role.member");
|
||||
}
|
||||
@@ -880,7 +914,7 @@
|
||||
<span class="truncate">{i18n.t("settings.tab.profile")}</span>
|
||||
{#if !tabAllowed("profile")}
|
||||
<Lock class="ml-auto h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||
<span class="sr-only">{i18n.t("settings.tabLockedHint")}</span>
|
||||
<span class="sr-only">{tabLockedHint("profile")}</span>
|
||||
{/if}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="company" class="shrink-0 justify-start md:w-full">
|
||||
@@ -888,7 +922,7 @@
|
||||
<span class="truncate">{i18n.t("settings.tab.company")}</span>
|
||||
{#if !tabAllowed("company")}
|
||||
<Lock class="ml-auto h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||
<span class="sr-only">{i18n.t("settings.tabLockedHint")}</span>
|
||||
<span class="sr-only">{tabLockedHint("company")}</span>
|
||||
{/if}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="alerts" class="shrink-0 justify-start md:w-full">
|
||||
@@ -896,7 +930,7 @@
|
||||
<span class="truncate">{i18n.t("settings.tab.alerts")}</span>
|
||||
{#if !tabAllowed("alerts")}
|
||||
<Lock class="ml-auto h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||
<span class="sr-only">{i18n.t("settings.tabLockedHint")}</span>
|
||||
<span class="sr-only">{tabLockedHint("alerts")}</span>
|
||||
{/if}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="api-keys" class="shrink-0 justify-start md:w-full" data-testid="settings-tab-api-keys">
|
||||
@@ -904,7 +938,7 @@
|
||||
<span class="truncate">{i18n.t("settings.tab.apiKeys")}</span>
|
||||
{#if !tabAllowed("api-keys")}
|
||||
<Lock class="ml-auto h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||
<span class="sr-only">{i18n.t("settings.tabLockedHint")}</span>
|
||||
<span class="sr-only">{tabLockedHint("api-keys")}</span>
|
||||
{/if}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="team" class="shrink-0 justify-start md:w-full">
|
||||
@@ -912,7 +946,7 @@
|
||||
<span class="truncate">{i18n.t("settings.tab.team")}</span>
|
||||
{#if !tabAllowed("team")}
|
||||
<Lock class="ml-auto h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||
<span class="sr-only">{i18n.t("settings.tabLockedHint")}</span>
|
||||
<span class="sr-only">{tabLockedHint("team")}</span>
|
||||
{/if}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
@@ -1644,6 +1678,16 @@
|
||||
{#if member.is_owner}
|
||||
<Badge variant="secondary">{i18n.t("settings.ownerBadge")}</Badge>
|
||||
{/if}
|
||||
{#if member.restricted}
|
||||
<Badge
|
||||
variant="outline"
|
||||
title={i18n.t("settings.permissions.restrictedHint", {
|
||||
count: member.denied_count ?? 0
|
||||
})}
|
||||
>
|
||||
{i18n.t("settings.permissions.restrictedBadge")}
|
||||
</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
@@ -1680,6 +1724,9 @@
|
||||
<MoreVertical class="h-4 w-4" />
|
||||
</Button>
|
||||
{/snippet}
|
||||
<DropdownMenuItem onclick={() => openPermissions(member)}>
|
||||
{i18n.t("settings.permissions.action")}
|
||||
</DropdownMenuItem>
|
||||
{#if member.role === "admin"}
|
||||
<DropdownMenuItem
|
||||
disabled={saving || !canDemoteMember(member)}
|
||||
@@ -1953,3 +2000,16 @@
|
||||
{/snippet}
|
||||
</Dialog>
|
||||
|
||||
<!-- Per-member permissions -->
|
||||
<MemberPermissionsDialog
|
||||
bind:open={permissionsOpen}
|
||||
member={permissionsMember
|
||||
? {
|
||||
user_id: String(permissionsMember.user_id ?? permissionsMember.id),
|
||||
email: permissionsMember.email,
|
||||
is_owner: permissionsMember.is_owner
|
||||
}
|
||||
: null}
|
||||
canEdit={canOwner}
|
||||
onSaved={applyPermissionsSaved}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user