From 0c154254c3e66ee1c851feb4725d6e8d6b118dca Mon Sep 17 00:00:00 2001 From: GreenEclipse Date: Sat, 22 Aug 2026 18:51:17 +0200 Subject: [PATCH] major fixes --- apps/api/internal/auth/member_permissions.go | 95 +++++ .../internal/billing/member_permissions.go | 332 ++++++++++++++++++ .../billing/member_permissions_test.go | 181 ++++++++++ apps/api/internal/billing/plan_features.go | 5 + apps/api/internal/billing/service.go | 4 + apps/api/internal/httpapi/auth_handlers.go | 12 + apps/api/internal/httpapi/company_handlers.go | 10 + .../httpapi/member_feature_middleware.go | 127 +++++++ .../httpapi/member_feature_middleware_test.go | 72 ++++ .../httpapi/plan_features_handlers.go | 7 +- apps/api/internal/httpapi/server.go | 4 + .../httpapi/team_permissions_handlers.go | 168 +++++++++ .../api/sql/schema/044_member_permissions.sql | 23 ++ apps/web/src/lib/billing-display.ts | 4 + .../components/AccessRestrictedPanel.svelte | 41 +++ .../src/lib/components/PlanRouteGuard.svelte | 5 + apps/web/src/lib/components/products/types.ts | 38 +- .../settings/MemberPermissionsDialog.svelte | 239 +++++++++++++ apps/web/src/lib/i18n/messages/de.ts | 21 ++ apps/web/src/lib/i18n/messages/en.ts | 23 +- apps/web/src/lib/i18n/messages/es.ts | 21 ++ apps/web/src/lib/i18n/messages/fr.ts | 21 ++ apps/web/src/lib/i18n/messages/it.ts | 21 ++ apps/web/src/lib/i18n/messages/ja.ts | 21 ++ apps/web/src/lib/i18n/messages/nl.ts | 21 ++ apps/web/src/lib/i18n/messages/pl.ts | 21 ++ apps/web/src/lib/i18n/messages/pt.ts | 21 ++ apps/web/src/lib/member-permissions.test.ts | 163 +++++++++ apps/web/src/lib/member-permissions.ts | 172 +++++++++ apps/web/src/lib/plan-capabilities.svelte.ts | 28 ++ apps/web/src/lib/plan-capabilities.ts | 4 + apps/web/src/lib/product-images.test.ts | 108 ++++++ apps/web/src/lib/product-images.ts | 141 ++++++++ apps/web/src/lib/server/csp.ts | 6 +- apps/web/src/lib/types.ts | 4 + apps/web/src/routes/settings/+page.svelte | 70 +++- 36 files changed, 2212 insertions(+), 42 deletions(-) create mode 100644 apps/api/internal/auth/member_permissions.go create mode 100644 apps/api/internal/billing/member_permissions.go create mode 100644 apps/api/internal/billing/member_permissions_test.go create mode 100644 apps/api/internal/httpapi/member_feature_middleware.go create mode 100644 apps/api/internal/httpapi/member_feature_middleware_test.go create mode 100644 apps/api/internal/httpapi/team_permissions_handlers.go create mode 100644 apps/api/sql/schema/044_member_permissions.sql create mode 100644 apps/web/src/lib/components/AccessRestrictedPanel.svelte create mode 100644 apps/web/src/lib/components/settings/MemberPermissionsDialog.svelte create mode 100644 apps/web/src/lib/member-permissions.test.ts create mode 100644 apps/web/src/lib/member-permissions.ts create mode 100644 apps/web/src/lib/product-images.test.ts create mode 100644 apps/web/src/lib/product-images.ts diff --git a/apps/api/internal/auth/member_permissions.go b/apps/api/internal/auth/member_permissions.go new file mode 100644 index 0000000..d9c3b7f --- /dev/null +++ b/apps/api/internal/auth/member_permissions.go @@ -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() +} diff --git a/apps/api/internal/billing/member_permissions.go b/apps/api/internal/billing/member_permissions.go new file mode 100644 index 0000000..dfc0cf7 --- /dev/null +++ b/apps/api/internal/billing/member_permissions.go @@ -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") diff --git a/apps/api/internal/billing/member_permissions_test.go b/apps/api/internal/billing/member_permissions_test.go new file mode 100644 index 0000000..c35bf12 --- /dev/null +++ b/apps/api/internal/billing/member_permissions_test.go @@ -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) + } +} diff --git a/apps/api/internal/billing/plan_features.go b/apps/api/internal/billing/plan_features.go index 91cd45a..4a925c8 100644 --- a/apps/api/internal/billing/plan_features.go +++ b/apps/api/internal/billing/plan_features.go @@ -50,6 +50,11 @@ type Capabilities struct { DisabledFeatures []string `json:"disabled_features"` FeatureETag string `json:"feature_etag"` Entitlements Entitlements `json:"entitlements"` + // MemberRestricted is true when the company owner narrowed this member's access. + // MemberDeniedFeatures lists the keys the plan allows but the member overlay denies, + // so the UI can say "ask your administrator" instead of "upgrade your plan". + MemberRestricted bool `json:"member_restricted,omitempty"` + MemberDeniedFeatures []string `json:"member_denied_features,omitempty"` } // FeatureGatesUpdate is the PUT /api/admin/feature-gates body. diff --git a/apps/api/internal/billing/service.go b/apps/api/internal/billing/service.go index c13a322..618498e 100644 --- a/apps/api/internal/billing/service.go +++ b/apps/api/internal/billing/service.go @@ -58,6 +58,10 @@ type CreditsOverview struct { Sections map[string]bool `json:"sections,omitempty"` DisabledFeatures []string `json:"disabled_features,omitempty"` FeatureETag string `json:"feature_etag,omitempty"` + // Per-member access overlay (settings > team). Set by the /me handler so the + // dashboard can say "ask your administrator" rather than "upgrade your plan". + MemberRestricted bool `json:"member_restricted,omitempty"` + MemberDeniedFeatures []string `json:"member_denied_features,omitempty"` } func (s *Service) CreditsOverview(ctx context.Context, companyID uuid.UUID, lowThreshold int) (CreditsOverview, error) { diff --git a/apps/api/internal/httpapi/auth_handlers.go b/apps/api/internal/httpapi/auth_handlers.go index a0ed8e6..ab2111a 100644 --- a/apps/api/internal/httpapi/auth_handlers.go +++ b/apps/api/internal/httpapi/auth_handlers.go @@ -7,6 +7,7 @@ import ( "strings" "github.com/descrybe/descrybe-v2/apps/api/internal/auth" + "github.com/descrybe/descrybe-v2/apps/api/internal/billing" "github.com/google/uuid" ) @@ -359,6 +360,17 @@ func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) { } } if credits, err := s.Billing.CreditsOverview(r.Context(), cid, s.Config.LowCreditsThreshold); err == nil { + // /me seeds the dashboard capability matrix before /billing/capabilities + // lands, so the per-member overlay must be applied here too — otherwise a + // restricted member sees a flash of the full nav on every page load. + if perms, perr := s.Billing.MemberPermissions(r.Context(), cid, uid); perr == nil && len(perms) > 0 { + features, denied := billing.ApplyMemberPermissions(credits.Features, perms) + credits.Features = features + credits.DisabledFeatures = append(credits.DisabledFeatures, denied...) + credits.FeatureETag = billing.FeatureETag(features) + credits.MemberRestricted = true + credits.MemberDeniedFeatures = denied + } out["credits"] = credits } if m, err := s.Auth.EnsureMembership(r.Context(), uid, cid); err == nil { diff --git a/apps/api/internal/httpapi/company_handlers.go b/apps/api/internal/httpapi/company_handlers.go index 2e49c39..5548e50 100644 --- a/apps/api/internal/httpapi/company_handlers.go +++ b/apps/api/internal/httpapi/company_handlers.go @@ -180,7 +180,13 @@ func (s *Server) handleListTeam(w http.ResponseWriter, r *http.Request) { Email string `json:"email"` Name *string `json:"name"` IsOwner bool `json:"is_owner"` + // Restricted / DeniedCount summarise the owner-set access overlay so the team + // table can badge restricted members without a request per row. + Restricted bool `json:"restricted"` + DeniedCount int `json:"denied_count"` } + // Best effort: a missing permissions column (pre-migration) just means "no overlay". + overlays, _ := s.Auth.MemberPermissionsByCompany(r.Context(), cid) out := make([]member, 0) for rows.Next() { var m member @@ -189,6 +195,10 @@ func (s *Server) handleListTeam(w http.ResponseWriter, r *http.Request) { return } m.IsOwner = ownerUserID != nil && *ownerUserID == m.UserID + if !m.IsOwner { + m.DeniedCount = len(deniedKeys(overlays[m.UserID])) + m.Restricted = m.DeniedCount > 0 + } out = append(out, m) } resp := map[string]any{"members": out, "total": total, "limit": limit, "offset": offset} diff --git a/apps/api/internal/httpapi/member_feature_middleware.go b/apps/api/internal/httpapi/member_feature_middleware.go new file mode 100644 index 0000000..5a14212 --- /dev/null +++ b/apps/api/internal/httpapi/member_feature_middleware.go @@ -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) + }) +} diff --git a/apps/api/internal/httpapi/member_feature_middleware_test.go b/apps/api/internal/httpapi/member_feature_middleware_test.go new file mode 100644 index 0000000..42c4569 --- /dev/null +++ b/apps/api/internal/httpapi/member_feature_middleware_test.go @@ -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) + } + } +} diff --git a/apps/api/internal/httpapi/plan_features_handlers.go b/apps/api/internal/httpapi/plan_features_handlers.go index c73e659..334e1d5 100644 --- a/apps/api/internal/httpapi/plan_features_handlers.go +++ b/apps/api/internal/httpapi/plan_features_handlers.go @@ -11,7 +11,7 @@ import ( "github.com/google/uuid" ) -// GET /api/billing/capabilities — effective plan ∩ global features for the active company. +// GET /api/billing/capabilities — effective plan ∩ global ∩ member features for the caller. func (s *Server) handleGetCapabilities(w http.ResponseWriter, r *http.Request) { if s.Billing == nil { Error(w, http.StatusServiceUnavailable, "billing unavailable") @@ -22,7 +22,10 @@ func (s *Server) handleGetCapabilities(w http.ResponseWriter, r *http.Request) { Error(w, http.StatusUnauthorized, "company required") return } - caps, err := s.Billing.CapabilitiesForCompany(r.Context(), cid) + // Member-aware: the company owner's per-member overlay narrows what this caller + // sees, so nav / route guard / feature gates all hide the same surfaces at once. + uid, _ := UserIDFromContext(r.Context()) + caps, err := s.Billing.CapabilitiesForMember(r.Context(), cid, uid) if err != nil { Error(w, http.StatusInternalServerError, "failed to load capabilities") return diff --git a/apps/api/internal/httpapi/server.go b/apps/api/internal/httpapi/server.go index 95bb16f..c487778 100644 --- a/apps/api/internal/httpapi/server.go +++ b/apps/api/internal/httpapi/server.go @@ -436,6 +436,7 @@ func (s *Server) Router() http.Handler { r.Route("/api", func(r chi.Router) { r.Use(s.RequireSession) r.Use(s.RequireCompany) + r.Use(s.RequireMemberFeature) r.Use(s.RateLimitMarketing) r.Use(s.RateLimitAIProbes) r.Use(s.RateLimitV1Process) @@ -453,8 +454,11 @@ func (s *Server) Router() http.Handler { r.Get("/team/invites", s.handleListInvites) r.Delete("/team/invites/{inviteID}", s.handleRevokeInvite) r.Post("/team/transfer-ownership", s.handleTransferOwnership) + r.Get("/team/permission-catalog", s.handleGetPermissionCatalog) r.Patch("/team/{userID}", s.handleUpdateMemberRole) r.Delete("/team/{userID}", s.handleRemoveMember) + r.Get("/team/{userID}/permissions", s.handleGetMemberPermissions) + r.Put("/team/{userID}/permissions", s.handlePutMemberPermissions) r.Get("/api-keys", s.handleListAPIKeys) r.Post("/api-keys", s.handleCreateAPIKey) diff --git a/apps/api/internal/httpapi/team_permissions_handlers.go b/apps/api/internal/httpapi/team_permissions_handlers.go new file mode 100644 index 0000000..5dd2aab --- /dev/null +++ b/apps/api/internal/httpapi/team_permissions_handlers.go @@ -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") +} diff --git a/apps/api/sql/schema/044_member_permissions.sql b/apps/api/sql/schema/044_member_permissions.sql new file mode 100644 index 0000000..910e331 --- /dev/null +++ b/apps/api/sql/schema/044_member_permissions.sql @@ -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; diff --git a/apps/web/src/lib/billing-display.ts b/apps/web/src/lib/billing-display.ts index 3a2c330..c109ab2 100644 --- a/apps/web/src/lib/billing-display.ts +++ b/apps/web/src/lib/billing-display.ts @@ -35,6 +35,10 @@ export type CreditsLike = { sections?: Record; disabled_features?: string[]; feature_etag?: string; + /** True when the company owner narrowed this member's access (settings > team). */ + member_restricted?: boolean; + /** Keys the plan allows but the member overlay denies. */ + member_denied_features?: string[]; }; export type UpgradeCta = { diff --git a/apps/web/src/lib/components/AccessRestrictedPanel.svelte b/apps/web/src/lib/components/AccessRestrictedPanel.svelte new file mode 100644 index 0000000..b4ac4e8 --- /dev/null +++ b/apps/web/src/lib/components/AccessRestrictedPanel.svelte @@ -0,0 +1,41 @@ + + +
+
+ +
+

+ {i18n.t("access.restricted.title")} +

+

+ {i18n.t("access.restricted.message")} +

+ {#if featureKey} +

+ {i18n.t("access.restricted.featureHint", { feature: featureKey })} +

+ {/if} +
+
+
diff --git a/apps/web/src/lib/components/PlanRouteGuard.svelte b/apps/web/src/lib/components/PlanRouteGuard.svelte index 4a972e4..7ceabee 100644 --- a/apps/web/src/lib/components/PlanRouteGuard.svelte +++ b/apps/web/src/lib/components/PlanRouteGuard.svelte @@ -9,6 +9,7 @@ } from "$lib/plan-capabilities"; import { upgradeMessageForFeature } from "$lib/plan-upgrade-message"; import PlanUpgradePanel from "$lib/components/PlanUpgradePanel.svelte"; + import AccessRestrictedPanel from "$lib/components/AccessRestrictedPanel.svelte"; import Spinner from "$lib/components/Spinner.svelte"; import { i18n } from "$lib/i18n"; @@ -30,6 +31,8 @@ const allowed = $derived( !gated || !featureKey || (!awaitingMatrix && planCapabilities.can(featureKey)) ); + /** Owner-set member restriction — a plan upgrade would not unlock it. */ + const memberDenied = $derived(Boolean(featureKey) && planCapabilities.memberDenied(featureKey!)); const gate = $derived( featureKey ? upgradeMessageForFeature(featureKey, authSession.isCompanyAdmin, { @@ -48,6 +51,8 @@ {:else if allowed} {@render children()} +{:else if memberDenied} + {:else if gate} (); - const push = (raw: unknown) => { - if (typeof raw === "string") { - const url = raw.trim(); - if (!/^https?:\/\//i.test(url) || seen.has(url)) return; - seen.add(url); - out.push(url); - return; - } - if (Array.isArray(raw)) { - for (const item of raw) push(item); - return; - } - const rec = asRecord(raw); - if (!rec) return; - for (const key of ["url", "src", "href", "link"]) { - if (rec[key] != null) push(rec[key]); - } - }; - for (const key of ["main_image", "mainImage", "image", "image_url", "imageUrl"]) { - if (mapped[key] != null) push(mapped[key]); - } - for (const key of ["more_images", "moreImages", "images", "additional_images"]) { - if (mapped[key] != null) push(mapped[key]); - } - return out; -} - /** True when product carries an EPREL id or enriched energy-label payload. */ export function productHasEprel(product: ProductRow | null | undefined): boolean { if (!product) return false; diff --git a/apps/web/src/lib/components/settings/MemberPermissionsDialog.svelte b/apps/web/src/lib/components/settings/MemberPermissionsDialog.svelte new file mode 100644 index 0000000..5f6d463 --- /dev/null +++ b/apps/web/src/lib/components/settings/MemberPermissionsDialog.svelte @@ -0,0 +1,239 @@ + + + +
+ {#if loading} +
+ +
+ {:else if loadError} + + {:else} + {#if isOwnerMember} + + {:else if !canEdit} + + {/if} + +
+

+ {i18n.t("settings.permissions.summary", { + allowed: summary.allowed, + total: summary.total + })} +

+ {#if !readOnly} +
+ + +
+ {/if} +
+ + + +
+ {#if visibleSections.length === 0} +

+ {i18n.t("settings.permissions.noMatches")} +

+ {/if} + {#each visibleSections as section (section.id)} +
+

+ {sectionLabel(section.id)} +

+ {#each section.entries as entry (entry.key)} + {@const allowed = entry.plan_allowed && !isDenied(denied, entry.key)} + {@const locked = !entry.plan_allowed} +
+ toggle(entry.key, !allowed)} + /> + + {#if locked} + {i18n.t("settings.permissions.planLocked")} + {/if} +
+ {/each} +
+ {/each} +
+ {/if} +
+ + {#snippet footer()} + + {#if !readOnly} + + {/if} + {/snippet} +
diff --git a/apps/web/src/lib/i18n/messages/de.ts b/apps/web/src/lib/i18n/messages/de.ts index adb8b86..46db7ca 100644 --- a/apps/web/src/lib/i18n/messages/de.ts +++ b/apps/web/src/lib/i18n/messages/de.ts @@ -5658,4 +5658,25 @@ export const de: MessageDict = { "files.empty": "Noch keine Uploads.", "products.filters.sortProducts": "Produkte sortieren", "products.table.options": "Optionen", + "access.restricted.title": "Zugriff eingeschränkt", + "access.restricted.message": "Der Inhaber Ihres Unternehmens hat den Zugriff auf diesen Bereich deaktiviert. Bitten Sie ihn, ihn bei Bedarf freizuschalten.", + "access.restricted.featureHint": "Berechtigung: {feature}", + "settings.permissions.action": "Berechtigungen", + "settings.permissions.title": "Berechtigungen für {email}", + "settings.permissions.description": "Legen Sie fest, was dieses Teammitglied öffnen darf. Ein deaktivierter Bereich wird in der Seitenleiste ausgeblendet und in der API gesperrt.", + "settings.permissions.ownerNotice": "Der Unternehmensinhaber hat immer vollen Zugriff und kann nicht eingeschränkt werden.", + "settings.permissions.ownerOnly": "Nur der Unternehmensinhaber kann Berechtigungen ändern.", + "settings.permissions.summary": "{allowed} von {total} Bereichen erlaubt", + "settings.permissions.allowAll": "Alle erlauben", + "settings.permissions.denyAll": "Alle sperren", + "settings.permissions.planLocked": "Nicht in Ihrem Tarif", + "settings.permissions.searchPlaceholder": "Bereiche suchen…", + "settings.permissions.noMatches": "Keine Bereiche passen zu dieser Suche.", + "settings.permissions.save": "Berechtigungen speichern", + "settings.permissions.saved": "Berechtigungen für {email} aktualisiert", + "settings.permissions.saveFailed": "Berechtigungen konnten nicht aktualisiert werden", + "settings.permissions.loadFailed": "Berechtigungen konnten nicht geladen werden", + "settings.permissions.restrictedBadge": "Eingeschränkt", + "settings.permissions.restrictedHint": "{count} Bereiche deaktiviert", + "settings.tabLockedByAdmin": "Vom Unternehmensinhaber deaktiviert" }; diff --git a/apps/web/src/lib/i18n/messages/en.ts b/apps/web/src/lib/i18n/messages/en.ts index 9a8cf7c..6d946fa 100644 --- a/apps/web/src/lib/i18n/messages/en.ts +++ b/apps/web/src/lib/i18n/messages/en.ts @@ -5743,5 +5743,26 @@ export const en: MessageDict = { "files.loading": "Loading uploads…", "files.empty": "No uploads yet.", "products.filters.sortProducts": "Sort Products", - "products.table.options": "Options" + "products.table.options": "Options", + "access.restricted.title": "Access restricted", + "access.restricted.message": "Your company owner has turned off access to this area. Ask them to enable it if you need it.", + "access.restricted.featureHint": "Permission: {feature}", + "settings.permissions.action": "Permissions", + "settings.permissions.title": "Permissions for {email}", + "settings.permissions.description": "Choose what this teammate can open. Turning an area off hides it from their sidebar and blocks it in the API.", + "settings.permissions.ownerNotice": "The company owner always has full access and cannot be restricted.", + "settings.permissions.ownerOnly": "Only the company owner can change permissions.", + "settings.permissions.summary": "{allowed} of {total} areas allowed", + "settings.permissions.allowAll": "Allow all", + "settings.permissions.denyAll": "Deny all", + "settings.permissions.planLocked": "Not in your plan", + "settings.permissions.searchPlaceholder": "Search areas…", + "settings.permissions.noMatches": "No areas match that search.", + "settings.permissions.save": "Save permissions", + "settings.permissions.saved": "Permissions updated for {email}", + "settings.permissions.saveFailed": "Could not update permissions", + "settings.permissions.loadFailed": "Could not load permissions", + "settings.permissions.restrictedBadge": "Restricted", + "settings.permissions.restrictedHint": "{count} areas turned off", + "settings.tabLockedByAdmin": "Turned off by your company owner" }; diff --git a/apps/web/src/lib/i18n/messages/es.ts b/apps/web/src/lib/i18n/messages/es.ts index 634175e..103c280 100644 --- a/apps/web/src/lib/i18n/messages/es.ts +++ b/apps/web/src/lib/i18n/messages/es.ts @@ -5667,4 +5667,25 @@ export const es: MessageDict = { "files.empty": "Aún no hay cargas.", "products.filters.sortProducts": "Ordenar productos", "products.table.options": "Opciones", + "access.restricted.title": "Acceso restringido", + "access.restricted.message": "El propietario de tu empresa ha desactivado el acceso a esta sección. Pídele que la active si la necesitas.", + "access.restricted.featureHint": "Permiso: {feature}", + "settings.permissions.action": "Permisos", + "settings.permissions.title": "Permisos de {email}", + "settings.permissions.description": "Elige qué puede abrir este miembro. Al desactivar una sección se oculta de su menú lateral y se bloquea en la API.", + "settings.permissions.ownerNotice": "El propietario de la empresa siempre tiene acceso completo y no se puede restringir.", + "settings.permissions.ownerOnly": "Solo el propietario de la empresa puede cambiar los permisos.", + "settings.permissions.summary": "{allowed} de {total} secciones permitidas", + "settings.permissions.allowAll": "Permitir todo", + "settings.permissions.denyAll": "Denegar todo", + "settings.permissions.planLocked": "No incluido en tu plan", + "settings.permissions.searchPlaceholder": "Buscar secciones…", + "settings.permissions.noMatches": "Ninguna sección coincide con la búsqueda.", + "settings.permissions.save": "Guardar permisos", + "settings.permissions.saved": "Permisos actualizados para {email}", + "settings.permissions.saveFailed": "No se pudieron actualizar los permisos", + "settings.permissions.loadFailed": "No se pudieron cargar los permisos", + "settings.permissions.restrictedBadge": "Restringido", + "settings.permissions.restrictedHint": "{count} secciones desactivadas", + "settings.tabLockedByAdmin": "Desactivado por el propietario de tu empresa" }; diff --git a/apps/web/src/lib/i18n/messages/fr.ts b/apps/web/src/lib/i18n/messages/fr.ts index 64a353b..1dba6d2 100644 --- a/apps/web/src/lib/i18n/messages/fr.ts +++ b/apps/web/src/lib/i18n/messages/fr.ts @@ -5667,4 +5667,25 @@ export const fr: MessageDict = { "files.empty": "Aucun téléversement pour l’instant.", "products.filters.sortProducts": "Trier les produits", "products.table.options": "Options", + "access.restricted.title": "Accès restreint", + "access.restricted.message": "Le propriétaire de votre entreprise a désactivé l’accès à cette section. Demandez-lui de l’activer si vous en avez besoin.", + "access.restricted.featureHint": "Autorisation : {feature}", + "settings.permissions.action": "Autorisations", + "settings.permissions.title": "Autorisations de {email}", + "settings.permissions.description": "Choisissez ce que ce membre peut ouvrir. Désactiver une section la masque dans sa barre latérale et la bloque dans l’API.", + "settings.permissions.ownerNotice": "Le propriétaire de l’entreprise a toujours un accès complet et ne peut pas être restreint.", + "settings.permissions.ownerOnly": "Seul le propriétaire de l’entreprise peut modifier les autorisations.", + "settings.permissions.summary": "{allowed} sur {total} sections autorisées", + "settings.permissions.allowAll": "Tout autoriser", + "settings.permissions.denyAll": "Tout refuser", + "settings.permissions.planLocked": "Absent de votre forfait", + "settings.permissions.searchPlaceholder": "Rechercher des sections…", + "settings.permissions.noMatches": "Aucune section ne correspond à cette recherche.", + "settings.permissions.save": "Enregistrer les autorisations", + "settings.permissions.saved": "Autorisations mises à jour pour {email}", + "settings.permissions.saveFailed": "Impossible de mettre à jour les autorisations", + "settings.permissions.loadFailed": "Impossible de charger les autorisations", + "settings.permissions.restrictedBadge": "Restreint", + "settings.permissions.restrictedHint": "{count} sections désactivées", + "settings.tabLockedByAdmin": "Désactivé par le propriétaire de votre entreprise" }; diff --git a/apps/web/src/lib/i18n/messages/it.ts b/apps/web/src/lib/i18n/messages/it.ts index fec4c49..48e6e39 100644 --- a/apps/web/src/lib/i18n/messages/it.ts +++ b/apps/web/src/lib/i18n/messages/it.ts @@ -5667,4 +5667,25 @@ export const it: MessageDict = { "files.empty": "Nessun caricamento ancora.", "products.filters.sortProducts": "Ordina prodotti", "products.table.options": "Opzioni", + "access.restricted.title": "Accesso limitato", + "access.restricted.message": "Il proprietario della tua azienda ha disattivato l’accesso a questa sezione. Chiedigli di abilitarla se ti serve.", + "access.restricted.featureHint": "Autorizzazione: {feature}", + "settings.permissions.action": "Autorizzazioni", + "settings.permissions.title": "Autorizzazioni per {email}", + "settings.permissions.description": "Scegli cosa può aprire questo membro del team. Disattivare una sezione la nasconde dalla barra laterale e la blocca nell’API.", + "settings.permissions.ownerNotice": "Il proprietario dell’azienda ha sempre accesso completo e non può essere limitato.", + "settings.permissions.ownerOnly": "Solo il proprietario dell’azienda può modificare le autorizzazioni.", + "settings.permissions.summary": "{allowed} di {total} sezioni consentite", + "settings.permissions.allowAll": "Consenti tutto", + "settings.permissions.denyAll": "Nega tutto", + "settings.permissions.planLocked": "Non incluso nel tuo piano", + "settings.permissions.searchPlaceholder": "Cerca sezioni…", + "settings.permissions.noMatches": "Nessuna sezione corrisponde alla ricerca.", + "settings.permissions.save": "Salva autorizzazioni", + "settings.permissions.saved": "Autorizzazioni aggiornate per {email}", + "settings.permissions.saveFailed": "Impossibile aggiornare le autorizzazioni", + "settings.permissions.loadFailed": "Impossibile caricare le autorizzazioni", + "settings.permissions.restrictedBadge": "Limitato", + "settings.permissions.restrictedHint": "{count} sezioni disattivate", + "settings.tabLockedByAdmin": "Disattivato dal proprietario della tua azienda" }; diff --git a/apps/web/src/lib/i18n/messages/ja.ts b/apps/web/src/lib/i18n/messages/ja.ts index 2b29d87..301915a 100644 --- a/apps/web/src/lib/i18n/messages/ja.ts +++ b/apps/web/src/lib/i18n/messages/ja.ts @@ -5667,4 +5667,25 @@ export const ja: MessageDict = { "files.empty": "まだアップロードはありません。", "products.filters.sortProducts": "商品を並べ替え", "products.table.options": "オプション", + "access.restricted.title": "アクセスが制限されています", + "access.restricted.message": "会社のオーナーがこのエリアへのアクセスを無効にしています。必要な場合は有効化を依頼してください。", + "access.restricted.featureHint": "権限: {feature}", + "settings.permissions.action": "権限", + "settings.permissions.title": "{email} の権限", + "settings.permissions.description": "このメンバーが開ける範囲を選びます。オフにしたエリアはサイドバーから非表示になり、API でもブロックされます。", + "settings.permissions.ownerNotice": "会社のオーナーは常にフルアクセスを持ち、制限できません。", + "settings.permissions.ownerOnly": "権限を変更できるのは会社のオーナーだけです。", + "settings.permissions.summary": "{total} 件中 {allowed} 件のエリアを許可", + "settings.permissions.allowAll": "すべて許可", + "settings.permissions.denyAll": "すべて拒否", + "settings.permissions.planLocked": "現在のプランに含まれません", + "settings.permissions.searchPlaceholder": "エリアを検索…", + "settings.permissions.noMatches": "検索に一致するエリアはありません。", + "settings.permissions.save": "権限を保存", + "settings.permissions.saved": "{email} の権限を更新しました", + "settings.permissions.saveFailed": "権限を更新できませんでした", + "settings.permissions.loadFailed": "権限を読み込めませんでした", + "settings.permissions.restrictedBadge": "制限あり", + "settings.permissions.restrictedHint": "{count} 件のエリアを無効化", + "settings.tabLockedByAdmin": "会社のオーナーが無効にしています" }; diff --git a/apps/web/src/lib/i18n/messages/nl.ts b/apps/web/src/lib/i18n/messages/nl.ts index 1348ab8..12be630 100644 --- a/apps/web/src/lib/i18n/messages/nl.ts +++ b/apps/web/src/lib/i18n/messages/nl.ts @@ -5667,4 +5667,25 @@ export const nl: MessageDict = { "files.empty": "Nog geen uploads.", "products.filters.sortProducts": "Producten sorteren", "products.table.options": "Opties", + "access.restricted.title": "Toegang beperkt", + "access.restricted.message": "De eigenaar van je bedrijf heeft toegang tot dit onderdeel uitgeschakeld. Vraag of het weer aangezet kan worden als je het nodig hebt.", + "access.restricted.featureHint": "Rechten: {feature}", + "settings.permissions.action": "Rechten", + "settings.permissions.title": "Rechten voor {email}", + "settings.permissions.description": "Bepaal wat dit teamlid mag openen. Een uitgeschakeld onderdeel verdwijnt uit de zijbalk en wordt geblokkeerd in de API.", + "settings.permissions.ownerNotice": "De bedrijfseigenaar heeft altijd volledige toegang en kan niet worden beperkt.", + "settings.permissions.ownerOnly": "Alleen de bedrijfseigenaar kan rechten wijzigen.", + "settings.permissions.summary": "{allowed} van {total} onderdelen toegestaan", + "settings.permissions.allowAll": "Alles toestaan", + "settings.permissions.denyAll": "Alles blokkeren", + "settings.permissions.planLocked": "Niet in je abonnement", + "settings.permissions.searchPlaceholder": "Onderdelen zoeken…", + "settings.permissions.noMatches": "Geen onderdelen gevonden voor deze zoekopdracht.", + "settings.permissions.save": "Rechten opslaan", + "settings.permissions.saved": "Rechten bijgewerkt voor {email}", + "settings.permissions.saveFailed": "Rechten konden niet worden bijgewerkt", + "settings.permissions.loadFailed": "Rechten konden niet worden geladen", + "settings.permissions.restrictedBadge": "Beperkt", + "settings.permissions.restrictedHint": "{count} onderdelen uitgeschakeld", + "settings.tabLockedByAdmin": "Uitgeschakeld door de bedrijfseigenaar" }; diff --git a/apps/web/src/lib/i18n/messages/pl.ts b/apps/web/src/lib/i18n/messages/pl.ts index 5ebb4f1..07d712d 100644 --- a/apps/web/src/lib/i18n/messages/pl.ts +++ b/apps/web/src/lib/i18n/messages/pl.ts @@ -5667,4 +5667,25 @@ export const pl: MessageDict = { "files.empty": "Brak przesÅ‚aÅ„.", "products.filters.sortProducts": "Sortuj produkty", "products.table.options": "Opcje", + "access.restricted.title": "Dostęp ograniczony", + "access.restricted.message": "Właściciel firmy wyłączył dostęp do tej sekcji. Poproś go o włączenie, jeśli jej potrzebujesz.", + "access.restricted.featureHint": "Uprawnienie: {feature}", + "settings.permissions.action": "Uprawnienia", + "settings.permissions.title": "Uprawnienia dla {email}", + "settings.permissions.description": "Wybierz, co może otwierać ten członek zespołu. Wyłączona sekcja znika z jego menu bocznego i jest blokowana w API.", + "settings.permissions.ownerNotice": "Właściciel firmy zawsze ma pełny dostęp i nie można go ograniczać.", + "settings.permissions.ownerOnly": "Tylko właściciel firmy może zmieniać uprawnienia.", + "settings.permissions.summary": "Dozwolone {allowed} z {total} sekcji", + "settings.permissions.allowAll": "Zezwól na wszystko", + "settings.permissions.denyAll": "Zablokuj wszystko", + "settings.permissions.planLocked": "Niedostępne w Twoim planie", + "settings.permissions.searchPlaceholder": "Szukaj sekcji…", + "settings.permissions.noMatches": "Brak sekcji pasujących do wyszukiwania.", + "settings.permissions.save": "Zapisz uprawnienia", + "settings.permissions.saved": "Zaktualizowano uprawnienia dla {email}", + "settings.permissions.saveFailed": "Nie udało się zaktualizować uprawnień", + "settings.permissions.loadFailed": "Nie udało się wczytać uprawnień", + "settings.permissions.restrictedBadge": "Ograniczony", + "settings.permissions.restrictedHint": "Wyłączonych sekcji: {count}", + "settings.tabLockedByAdmin": "Wyłączone przez właściciela firmy" }; diff --git a/apps/web/src/lib/i18n/messages/pt.ts b/apps/web/src/lib/i18n/messages/pt.ts index d4825c0..f1bfe5f 100644 --- a/apps/web/src/lib/i18n/messages/pt.ts +++ b/apps/web/src/lib/i18n/messages/pt.ts @@ -5667,4 +5667,25 @@ export const pt: MessageDict = { "files.empty": "Ainda sem carregamentos.", "products.filters.sortProducts": "Ordenar produtos", "products.table.options": "Opções", + "access.restricted.title": "Acesso restrito", + "access.restricted.message": "O proprietário da sua empresa desativou o acesso a esta área. Peça-lhe para a ativar se precisar dela.", + "access.restricted.featureHint": "Permissão: {feature}", + "settings.permissions.action": "Permissões", + "settings.permissions.title": "Permissões de {email}", + "settings.permissions.description": "Escolha o que este membro pode abrir. Desativar uma área oculta-a da barra lateral e bloqueia-a na API.", + "settings.permissions.ownerNotice": "O proprietário da empresa tem sempre acesso total e não pode ser restringido.", + "settings.permissions.ownerOnly": "Só o proprietário da empresa pode alterar permissões.", + "settings.permissions.summary": "{allowed} de {total} áreas permitidas", + "settings.permissions.allowAll": "Permitir tudo", + "settings.permissions.denyAll": "Negar tudo", + "settings.permissions.planLocked": "Não incluído no seu plano", + "settings.permissions.searchPlaceholder": "Procurar áreas…", + "settings.permissions.noMatches": "Nenhuma área corresponde a essa procura.", + "settings.permissions.save": "Guardar permissões", + "settings.permissions.saved": "Permissões atualizadas para {email}", + "settings.permissions.saveFailed": "Não foi possível atualizar as permissões", + "settings.permissions.loadFailed": "Não foi possível carregar as permissões", + "settings.permissions.restrictedBadge": "Restrito", + "settings.permissions.restrictedHint": "{count} áreas desativadas", + "settings.tabLockedByAdmin": "Desativado pelo proprietário da sua empresa" }; diff --git a/apps/web/src/lib/member-permissions.test.ts b/apps/web/src/lib/member-permissions.test.ts new file mode 100644 index 0000000..2ec529a --- /dev/null +++ b/apps/web/src/lib/member-permissions.test.ts @@ -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"] + ); + }); +}); diff --git a/apps/web/src/lib/member-permissions.ts b/apps/web/src/lib/member-permissions.ts new file mode 100644 index 0000000..11bc509 --- /dev/null +++ b/apps/web/src/lib/member-permissions.ts @@ -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, 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, + 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, 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 +): { 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; +} diff --git a/apps/web/src/lib/plan-capabilities.svelte.ts b/apps/web/src/lib/plan-capabilities.svelte.ts index c5832ea..3b7dd37 100644 --- a/apps/web/src/lib/plan-capabilities.svelte.ts +++ b/apps/web/src/lib/plan-capabilities.svelte.ts @@ -14,6 +14,8 @@ let sectionsState = $state | null>(null); let statusState = $state("idle"); let etagState = $state(null); let planNameState = $state(null); +/** Feature keys the plan allows but the company owner denied for this member. */ +let memberDeniedState = $state>(new Set()); let fetchedOnce = false; /** Soft TTL so admin gate/plan edits show up without a full reload. */ let fetchedAtMs = 0; @@ -31,6 +33,13 @@ function applyCredits(credits?: CreditsLike | null) { if (sections && typeof sections === "object" && !Array.isArray(sections)) { sectionsState = sections; } + applyMemberDenied(credits?.member_denied_features); +} + +/** Track owner-set per-member denials so gates can say "ask your admin", not "upgrade". */ +function applyMemberDenied(denied?: string[] | null) { + if (!Array.isArray(denied)) return; + memberDeniedState = new Set(denied); } function applyCapabilities(payload: CapabilitiesResponse) { @@ -49,6 +58,7 @@ function applyCapabilities(payload: CapabilitiesResponse) { if (typeof payload.plan_name === "string" && payload.plan_name.trim()) { planNameState = payload.plan_name.trim(); } + applyMemberDenied(payload.member_denied_features); } /** Shared plan capabilities snapshot — fetch once per dashboard session. */ @@ -68,6 +78,23 @@ export const planCapabilities = { get planName(): string | null { return planNameState; }, + /** True when the company owner has narrowed this member's access at all. */ + get memberRestricted(): boolean { + return memberDeniedState.size > 0; + }, + /** + * True when THIS key is off because the company owner turned it off, rather than + * because the plan lacks it. Honours parent-prefix denial, matching the API. + */ + memberDenied(key: string): boolean { + if (memberDeniedState.size === 0) return false; + if (memberDeniedState.has(key)) return true; + const parts = key.split("."); + for (let i = 1; i < parts.length; i++) { + if (memberDeniedState.has(parts.slice(0, i).join("."))) return true; + } + return false; + }, /** Seed from /api/auth/me credits (preferred primary source per contract). */ hydrateFromCredits(credits?: CreditsLike | null) { applyCredits(credits); @@ -78,6 +105,7 @@ export const planCapabilities = { statusState = "idle"; etagState = null; planNameState = null; + memberDeniedState = new Set(); fetchedOnce = false; fetchedAtMs = 0; }, diff --git a/apps/web/src/lib/plan-capabilities.ts b/apps/web/src/lib/plan-capabilities.ts index 37bdfa2..50b6eed 100644 --- a/apps/web/src/lib/plan-capabilities.ts +++ b/apps/web/src/lib/plan-capabilities.ts @@ -18,6 +18,10 @@ export type CapabilitiesResponse = { sections?: Record; disabled_features?: string[]; feature_etag?: string; + /** True when the company owner narrowed this member's access (see settings > team). */ + member_restricted?: boolean; + /** Keys the plan allows but the member overlay denies — "ask your admin", not "upgrade". */ + member_denied_features?: string[]; entitlements?: { can_use_ai?: boolean; can_use_eprel?: boolean; diff --git a/apps/web/src/lib/product-images.test.ts b/apps/web/src/lib/product-images.test.ts new file mode 100644 index 0000000..0e4838b --- /dev/null +++ b/apps/web/src/lib/product-images.test.ts @@ -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" }), []); + }); +}); diff --git a/apps/web/src/lib/product-images.ts b/apps/web/src/lib/product-images.ts new file mode 100644 index 0000000..267f3aa --- /dev/null +++ b/apps/web/src/lib/product-images.ts @@ -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 | 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 | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + return value as Record; +} + +/** 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 => bag != null); + + const seen = new Set(); + 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; +} diff --git a/apps/web/src/lib/server/csp.ts b/apps/web/src/lib/server/csp.ts index ee3bc44..e0663ba 100644 --- a/apps/web/src/lib/server/csp.ts +++ b/apps/web/src/lib/server/csp.ts @@ -76,7 +76,11 @@ export function contentSecurityPolicy(opts: ContentSecurityPolicyOptions): strin connect.push(opts.apiOrigin); } const fontSrc = "font-src 'self' data:"; - const imgSrc = joinSrc("img-src 'self' data: blob:", gtmImg); + // https: — product images come from arbitrary supplier CDNs resolved at feed-sync + // time (mapped_data.main_image / moreimages), so the host set cannot be allowlisted. + // Images only: no script/style/connect relaxation, and referrerpolicy="no-referrer" + // on the tags keeps dashboard URLs out of supplier logs. + const imgSrc = joinSrc("img-src 'self' data: blob: https:", gtmImg); const frameSrc = joinSrc("frame-src 'self'", gtmFrame); if (opts.dev) { diff --git a/apps/web/src/lib/types.ts b/apps/web/src/lib/types.ts index 46d36c8..1ee5538 100644 --- a/apps/web/src/lib/types.ts +++ b/apps/web/src/lib/types.ts @@ -238,6 +238,10 @@ export type TeamMember = { status?: string | null; created_at?: string | null; is_owner?: boolean; + /** True when the company owner narrowed this member's dashboard access. */ + restricted?: boolean; + /** How many areas are turned off for this member (0 = full plan access). */ + denied_count?: number; }; export type ChannelSyncSummary = { diff --git a/apps/web/src/routes/settings/+page.svelte b/apps/web/src/routes/settings/+page.svelte index 1adb03e..9077688 100644 --- a/apps/web/src/routes/settings/+page.svelte +++ b/apps/web/src/routes/settings/+page.svelte @@ -64,6 +64,7 @@ } from "$lib/types"; import PageShell from "$lib/components/PageShell.svelte"; import Alert from "$lib/components/Alert.svelte"; + import MemberPermissionsDialog from "$lib/components/settings/MemberPermissionsDialog.svelte"; import Spinner from "$lib/components/Spinner.svelte"; import EmptyState from "$lib/components/EmptyState.svelte"; import ForbiddenEmptyState from "$lib/components/ForbiddenEmptyState.svelte"; @@ -113,6 +114,18 @@ return settingsTabAllowed(tab, (key) => planCapabilities.can(key)); } + /** + * Why a tab is locked: an owner-set member restriction reads differently from a plan + * limit — "upgrade" would be misleading advice when only an admin can re-enable it. + */ + function tabLockedHint(tab: Tab): string { + const key = SETTINGS_TAB_FEATURES[tab]; + if (key && planCapabilities.memberDenied(key)) { + return i18n.t("settings.tabLockedByAdmin"); + } + return i18n.t("settings.tabLockedHint"); + } + function firstAllowedTab(): Tab { return TAB_ORDER.find((t) => tabAllowed(t)) ?? "profile"; } @@ -187,6 +200,9 @@ let pendingInvites = $state([]); let canAdmin = $state(false); let canOwner = $state(false); + /** Per-member access overlay editor (owner-only edits; admins get a read-only view). */ + let permissionsOpen = $state(false); + let permissionsMember = $state(null); let accessDenied = $state(false); let teamForbidden = $state(false); let apiKeysForbidden = $state(false); @@ -843,6 +859,24 @@ return (member.user_id ?? member.id) === user.id; } + function openPermissions(member: TeamMember) { + permissionsMember = member; + permissionsOpen = true; + } + + /** Reflect the saved overlay in the team table without refetching the whole list. */ + function applyPermissionsSaved(view: { user_id: string; denied: string[] }) { + const count = view.denied?.length ?? 0; + team = team.map((m) => + (m.user_id ?? m.id) === view.user_id + ? { ...m, restricted: count > 0, denied_count: count } + : m + ); + success = i18n.t("settings.permissions.saved", { + email: team.find((m) => (m.user_id ?? m.id) === view.user_id)?.email ?? "" + }); + } + function roleLabel(role?: string | null): string { return role === "admin" ? i18n.t("settings.role.admin") : i18n.t("settings.role.member"); } @@ -880,7 +914,7 @@ {i18n.t("settings.tab.profile")} {#if !tabAllowed("profile")}