major fixes

This commit is contained in:
2026-08-22 18:51:17 +02:00
parent 0ff24b1534
commit 0c154254c3
36 changed files with 2212 additions and 42 deletions
@@ -0,0 +1,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"` DisabledFeatures []string `json:"disabled_features"`
FeatureETag string `json:"feature_etag"` FeatureETag string `json:"feature_etag"`
Entitlements Entitlements `json:"entitlements"` 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. // FeatureGatesUpdate is the PUT /api/admin/feature-gates body.
+4
View File
@@ -58,6 +58,10 @@ type CreditsOverview struct {
Sections map[string]bool `json:"sections,omitempty"` Sections map[string]bool `json:"sections,omitempty"`
DisabledFeatures []string `json:"disabled_features,omitempty"` DisabledFeatures []string `json:"disabled_features,omitempty"`
FeatureETag string `json:"feature_etag,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) { func (s *Service) CreditsOverview(ctx context.Context, companyID uuid.UUID, lowThreshold int) (CreditsOverview, error) {
@@ -7,6 +7,7 @@ import (
"strings" "strings"
"github.com/descrybe/descrybe-v2/apps/api/internal/auth" "github.com/descrybe/descrybe-v2/apps/api/internal/auth"
"github.com/descrybe/descrybe-v2/apps/api/internal/billing"
"github.com/google/uuid" "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 { 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 out["credits"] = credits
} }
if m, err := s.Auth.EnsureMembership(r.Context(), uid, cid); err == nil { 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"` Email string `json:"email"`
Name *string `json:"name"` Name *string `json:"name"`
IsOwner bool `json:"is_owner"` 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) out := make([]member, 0)
for rows.Next() { for rows.Next() {
var m member var m member
@@ -189,6 +195,10 @@ func (s *Server) handleListTeam(w http.ResponseWriter, r *http.Request) {
return return
} }
m.IsOwner = ownerUserID != nil && *ownerUserID == m.UserID 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) out = append(out, m)
} }
resp := map[string]any{"members": out, "total": total, "limit": limit, "offset": offset} 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" "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) { func (s *Server) handleGetCapabilities(w http.ResponseWriter, r *http.Request) {
if s.Billing == nil { if s.Billing == nil {
Error(w, http.StatusServiceUnavailable, "billing unavailable") 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") Error(w, http.StatusUnauthorized, "company required")
return 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 { if err != nil {
Error(w, http.StatusInternalServerError, "failed to load capabilities") Error(w, http.StatusInternalServerError, "failed to load capabilities")
return return
+4
View File
@@ -436,6 +436,7 @@ func (s *Server) Router() http.Handler {
r.Route("/api", func(r chi.Router) { r.Route("/api", func(r chi.Router) {
r.Use(s.RequireSession) r.Use(s.RequireSession)
r.Use(s.RequireCompany) r.Use(s.RequireCompany)
r.Use(s.RequireMemberFeature)
r.Use(s.RateLimitMarketing) r.Use(s.RateLimitMarketing)
r.Use(s.RateLimitAIProbes) r.Use(s.RateLimitAIProbes)
r.Use(s.RateLimitV1Process) r.Use(s.RateLimitV1Process)
@@ -453,8 +454,11 @@ func (s *Server) Router() http.Handler {
r.Get("/team/invites", s.handleListInvites) r.Get("/team/invites", s.handleListInvites)
r.Delete("/team/invites/{inviteID}", s.handleRevokeInvite) r.Delete("/team/invites/{inviteID}", s.handleRevokeInvite)
r.Post("/team/transfer-ownership", s.handleTransferOwnership) r.Post("/team/transfer-ownership", s.handleTransferOwnership)
r.Get("/team/permission-catalog", s.handleGetPermissionCatalog)
r.Patch("/team/{userID}", s.handleUpdateMemberRole) r.Patch("/team/{userID}", s.handleUpdateMemberRole)
r.Delete("/team/{userID}", s.handleRemoveMember) 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.Get("/api-keys", s.handleListAPIKeys)
r.Post("/api-keys", s.handleCreateAPIKey) 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;
+4
View File
@@ -35,6 +35,10 @@ export type CreditsLike = {
sections?: Record<string, boolean>; sections?: Record<string, boolean>;
disabled_features?: string[]; disabled_features?: string[];
feature_etag?: 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 = { 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"; } from "$lib/plan-capabilities";
import { upgradeMessageForFeature } from "$lib/plan-upgrade-message"; import { upgradeMessageForFeature } from "$lib/plan-upgrade-message";
import PlanUpgradePanel from "$lib/components/PlanUpgradePanel.svelte"; import PlanUpgradePanel from "$lib/components/PlanUpgradePanel.svelte";
import AccessRestrictedPanel from "$lib/components/AccessRestrictedPanel.svelte";
import Spinner from "$lib/components/Spinner.svelte"; import Spinner from "$lib/components/Spinner.svelte";
import { i18n } from "$lib/i18n"; import { i18n } from "$lib/i18n";
@@ -30,6 +31,8 @@
const allowed = $derived( const allowed = $derived(
!gated || !featureKey || (!awaitingMatrix && planCapabilities.can(featureKey)) !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( const gate = $derived(
featureKey featureKey
? upgradeMessageForFeature(featureKey, authSession.isCompanyAdmin, { ? upgradeMessageForFeature(featureKey, authSession.isCompanyAdmin, {
@@ -48,6 +51,8 @@
</div> </div>
{:else if allowed} {:else if allowed}
{@render children()} {@render children()}
{:else if memberDenied}
<AccessRestrictedPanel featureKey={featureKey ?? undefined} />
{:else if gate} {:else if gate}
<PlanUpgradePanel <PlanUpgradePanel
title={gate.title} title={gate.title}
+5 -33
View File
@@ -4,6 +4,11 @@ export type {
ProductListURLFilters, ProductListURLFilters,
ProductTab ProductTab
} from "../../products-search"; } from "../../products-search";
export {
coerceImageUrl,
coerceImageUrlList,
productImageUrls
} from "../../product-images";
export { export {
hasExplicitProductTab, hasExplicitProductTab,
isProductTab, isProductTab,
@@ -847,39 +852,6 @@ function digAttrBag(
return undefined; 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. */ /** True when product carries an EPREL id or enriched energy-label payload. */
export function productHasEprel(product: ProductRow | null | undefined): boolean { export function productHasEprel(product: ProductRow | null | undefined): boolean {
if (!product) return false; 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>
+21
View File
@@ -5658,4 +5658,25 @@ export const de: MessageDict = {
"files.empty": "Noch keine Uploads.", "files.empty": "Noch keine Uploads.",
"products.filters.sortProducts": "Produkte sortieren", "products.filters.sortProducts": "Produkte sortieren",
"products.table.options": "Optionen", "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"
}; };
+22 -1
View File
@@ -5743,5 +5743,26 @@ export const en: MessageDict = {
"files.loading": "Loading uploads…", "files.loading": "Loading uploads…",
"files.empty": "No uploads yet.", "files.empty": "No uploads yet.",
"products.filters.sortProducts": "Sort Products", "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"
}; };
+21
View File
@@ -5667,4 +5667,25 @@ export const es: MessageDict = {
"files.empty": "Aún no hay cargas.", "files.empty": "Aún no hay cargas.",
"products.filters.sortProducts": "Ordenar productos", "products.filters.sortProducts": "Ordenar productos",
"products.table.options": "Opciones", "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"
}; };
+21
View File
@@ -5667,4 +5667,25 @@ export const fr: MessageDict = {
"files.empty": "Aucun téléversement pour l’instant.", "files.empty": "Aucun téléversement pour l’instant.",
"products.filters.sortProducts": "Trier les produits", "products.filters.sortProducts": "Trier les produits",
"products.table.options": "Options", "products.table.options": "Options",
"access.restricted.title": "Accès restreint",
"access.restricted.message": "Le propriétaire de votre entreprise a désactivé laccès à cette section. Demandez-lui de lactiver 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 lAPI.",
"settings.permissions.ownerNotice": "Le propriétaire de lentreprise a toujours un accès complet et ne peut pas être restreint.",
"settings.permissions.ownerOnly": "Seul le propriétaire de lentreprise 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"
}; };
+21
View File
@@ -5667,4 +5667,25 @@ export const it: MessageDict = {
"files.empty": "Nessun caricamento ancora.", "files.empty": "Nessun caricamento ancora.",
"products.filters.sortProducts": "Ordina prodotti", "products.filters.sortProducts": "Ordina prodotti",
"products.table.options": "Opzioni", "products.table.options": "Opzioni",
"access.restricted.title": "Accesso limitato",
"access.restricted.message": "Il proprietario della tua azienda ha disattivato laccesso 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 nellAPI.",
"settings.permissions.ownerNotice": "Il proprietario dellazienda ha sempre accesso completo e non può essere limitato.",
"settings.permissions.ownerOnly": "Solo il proprietario dellazienda 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"
}; };
+21
View File
@@ -5667,4 +5667,25 @@ export const ja: MessageDict = {
"files.empty": "まだアップロードはありません。", "files.empty": "まだアップロードはありません。",
"products.filters.sortProducts": "商品を並べ替え", "products.filters.sortProducts": "商品を並べ替え",
"products.table.options": "オプション", "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": "会社のオーナーが無効にしています"
}; };
+21
View File
@@ -5667,4 +5667,25 @@ export const nl: MessageDict = {
"files.empty": "Nog geen uploads.", "files.empty": "Nog geen uploads.",
"products.filters.sortProducts": "Producten sorteren", "products.filters.sortProducts": "Producten sorteren",
"products.table.options": "Opties", "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"
}; };
+21
View File
@@ -5667,4 +5667,25 @@ export const pl: MessageDict = {
"files.empty": "Brak przesłań.", "files.empty": "Brak przesłań.",
"products.filters.sortProducts": "Sortuj produkty", "products.filters.sortProducts": "Sortuj produkty",
"products.table.options": "Opcje", "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"
}; };
+21
View File
@@ -5667,4 +5667,25 @@ export const pt: MessageDict = {
"files.empty": "Ainda sem carregamentos.", "files.empty": "Ainda sem carregamentos.",
"products.filters.sortProducts": "Ordenar produtos", "products.filters.sortProducts": "Ordenar produtos",
"products.table.options": "Opções", "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"
}; };
+163
View File
@@ -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"]
);
});
});
+172
View File
@@ -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 statusState = $state<CapabilitiesStatus>("idle");
let etagState = $state<string | null>(null); let etagState = $state<string | null>(null);
let planNameState = $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; let fetchedOnce = false;
/** Soft TTL so admin gate/plan edits show up without a full reload. */ /** Soft TTL so admin gate/plan edits show up without a full reload. */
let fetchedAtMs = 0; let fetchedAtMs = 0;
@@ -31,6 +33,13 @@ function applyCredits(credits?: CreditsLike | null) {
if (sections && typeof sections === "object" && !Array.isArray(sections)) { if (sections && typeof sections === "object" && !Array.isArray(sections)) {
sectionsState = 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) { function applyCapabilities(payload: CapabilitiesResponse) {
@@ -49,6 +58,7 @@ function applyCapabilities(payload: CapabilitiesResponse) {
if (typeof payload.plan_name === "string" && payload.plan_name.trim()) { if (typeof payload.plan_name === "string" && payload.plan_name.trim()) {
planNameState = payload.plan_name.trim(); planNameState = payload.plan_name.trim();
} }
applyMemberDenied(payload.member_denied_features);
} }
/** Shared plan capabilities snapshot — fetch once per dashboard session. */ /** Shared plan capabilities snapshot — fetch once per dashboard session. */
@@ -68,6 +78,23 @@ export const planCapabilities = {
get planName(): string | null { get planName(): string | null {
return planNameState; 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). */ /** Seed from /api/auth/me credits (preferred primary source per contract). */
hydrateFromCredits(credits?: CreditsLike | null) { hydrateFromCredits(credits?: CreditsLike | null) {
applyCredits(credits); applyCredits(credits);
@@ -78,6 +105,7 @@ export const planCapabilities = {
statusState = "idle"; statusState = "idle";
etagState = null; etagState = null;
planNameState = null; planNameState = null;
memberDeniedState = new Set();
fetchedOnce = false; fetchedOnce = false;
fetchedAtMs = 0; fetchedAtMs = 0;
}, },
+4
View File
@@ -18,6 +18,10 @@ export type CapabilitiesResponse = {
sections?: Record<string, boolean>; sections?: Record<string, boolean>;
disabled_features?: string[]; disabled_features?: string[];
feature_etag?: 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?: { entitlements?: {
can_use_ai?: boolean; can_use_ai?: boolean;
can_use_eprel?: boolean; can_use_eprel?: boolean;
+108
View File
@@ -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" }), []);
});
});
+141
View File
@@ -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;
}
+5 -1
View File
@@ -76,7 +76,11 @@ export function contentSecurityPolicy(opts: ContentSecurityPolicyOptions): strin
connect.push(opts.apiOrigin); connect.push(opts.apiOrigin);
} }
const fontSrc = "font-src 'self' data:"; 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); const frameSrc = joinSrc("frame-src 'self'", gtmFrame);
if (opts.dev) { if (opts.dev) {
+4
View File
@@ -238,6 +238,10 @@ export type TeamMember = {
status?: string | null; status?: string | null;
created_at?: string | null; created_at?: string | null;
is_owner?: boolean; 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 = { export type ChannelSyncSummary = {
+65 -5
View File
@@ -64,6 +64,7 @@
} from "$lib/types"; } from "$lib/types";
import PageShell from "$lib/components/PageShell.svelte"; import PageShell from "$lib/components/PageShell.svelte";
import Alert from "$lib/components/Alert.svelte"; import Alert from "$lib/components/Alert.svelte";
import MemberPermissionsDialog from "$lib/components/settings/MemberPermissionsDialog.svelte";
import Spinner from "$lib/components/Spinner.svelte"; import Spinner from "$lib/components/Spinner.svelte";
import EmptyState from "$lib/components/EmptyState.svelte"; import EmptyState from "$lib/components/EmptyState.svelte";
import ForbiddenEmptyState from "$lib/components/ForbiddenEmptyState.svelte"; import ForbiddenEmptyState from "$lib/components/ForbiddenEmptyState.svelte";
@@ -113,6 +114,18 @@
return settingsTabAllowed(tab, (key) => planCapabilities.can(key)); 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 { function firstAllowedTab(): Tab {
return TAB_ORDER.find((t) => tabAllowed(t)) ?? "profile"; return TAB_ORDER.find((t) => tabAllowed(t)) ?? "profile";
} }
@@ -187,6 +200,9 @@
let pendingInvites = $state<PendingInvite[]>([]); let pendingInvites = $state<PendingInvite[]>([]);
let canAdmin = $state(false); let canAdmin = $state(false);
let canOwner = $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 accessDenied = $state(false);
let teamForbidden = $state(false); let teamForbidden = $state(false);
let apiKeysForbidden = $state(false); let apiKeysForbidden = $state(false);
@@ -843,6 +859,24 @@
return (member.user_id ?? member.id) === user.id; 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 { function roleLabel(role?: string | null): string {
return role === "admin" ? i18n.t("settings.role.admin") : i18n.t("settings.role.member"); 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> <span class="truncate">{i18n.t("settings.tab.profile")}</span>
{#if !tabAllowed("profile")} {#if !tabAllowed("profile")}
<Lock class="ml-auto h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden="true" /> <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} {/if}
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="company" class="shrink-0 justify-start md:w-full"> <TabsTrigger value="company" class="shrink-0 justify-start md:w-full">
@@ -888,7 +922,7 @@
<span class="truncate">{i18n.t("settings.tab.company")}</span> <span class="truncate">{i18n.t("settings.tab.company")}</span>
{#if !tabAllowed("company")} {#if !tabAllowed("company")}
<Lock class="ml-auto h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden="true" /> <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} {/if}
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="alerts" class="shrink-0 justify-start md:w-full"> <TabsTrigger value="alerts" class="shrink-0 justify-start md:w-full">
@@ -896,7 +930,7 @@
<span class="truncate">{i18n.t("settings.tab.alerts")}</span> <span class="truncate">{i18n.t("settings.tab.alerts")}</span>
{#if !tabAllowed("alerts")} {#if !tabAllowed("alerts")}
<Lock class="ml-auto h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden="true" /> <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} {/if}
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="api-keys" class="shrink-0 justify-start md:w-full" data-testid="settings-tab-api-keys"> <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> <span class="truncate">{i18n.t("settings.tab.apiKeys")}</span>
{#if !tabAllowed("api-keys")} {#if !tabAllowed("api-keys")}
<Lock class="ml-auto h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden="true" /> <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} {/if}
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="team" class="shrink-0 justify-start md:w-full"> <TabsTrigger value="team" class="shrink-0 justify-start md:w-full">
@@ -912,7 +946,7 @@
<span class="truncate">{i18n.t("settings.tab.team")}</span> <span class="truncate">{i18n.t("settings.tab.team")}</span>
{#if !tabAllowed("team")} {#if !tabAllowed("team")}
<Lock class="ml-auto h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden="true" /> <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} {/if}
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
@@ -1644,6 +1678,16 @@
{#if member.is_owner} {#if member.is_owner}
<Badge variant="secondary">{i18n.t("settings.ownerBadge")}</Badge> <Badge variant="secondary">{i18n.t("settings.ownerBadge")}</Badge>
{/if} {/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> </div>
</TableCell> </TableCell>
<TableCell> <TableCell>
@@ -1680,6 +1724,9 @@
<MoreVertical class="h-4 w-4" /> <MoreVertical class="h-4 w-4" />
</Button> </Button>
{/snippet} {/snippet}
<DropdownMenuItem onclick={() => openPermissions(member)}>
{i18n.t("settings.permissions.action")}
</DropdownMenuItem>
{#if member.role === "admin"} {#if member.role === "admin"}
<DropdownMenuItem <DropdownMenuItem
disabled={saving || !canDemoteMember(member)} disabled={saving || !canDemoteMember(member)}
@@ -1953,3 +2000,16 @@
{/snippet} {/snippet}
</Dialog> </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}
/>