diff --git a/apps/api/internal/catalog/service.go b/apps/api/internal/catalog/service.go
index f194bc5..d929e0d 100644
--- a/apps/api/internal/catalog/service.go
+++ b/apps/api/internal/catalog/service.go
@@ -673,7 +673,7 @@ const (
processedPreferredDescriptionSQL = `COALESCE(NULLIF(p.processed_description, ''), NULLIF(p.description, ''), NULLIF(r.mapped_data->>'description', ''), '')`
processedHasNameSQL = `(` + processedPreferredNameSQL + ` <> '')`
processedHasDescriptionSQL = `(` + processedPreferredDescriptionSQL + ` <> '')`
- processedHasCategorySQL = `(COALESCE(NULLIF(p.category, ''), '') <> '' AND lower(p.category) <> 'none')`
+ processedHasCategorySQL = `(COALESCE(NULLIF(p.category, ''), '') <> '' AND lower(p.category) <> 'none')`
// Resolve display name / canonical unique_id when products store unique_id, UUID id, or name.
processedCategoryResolveJoin = `
LEFT JOIN LATERAL (
@@ -770,8 +770,34 @@ var (
OR COALESCE(NULLIF(trim(r.mapped_data->>'eprel'), ''), '') <> ''
OR COALESCE(NULLIF(trim(r.mapped_data->>'EPRELID'), ''), '') <> ''
)`
+
+ // listMainImageSQL is the first usable image URL for a product row, so the list
+ // can show a thumbnail without shipping the whole mapped_data blob per row.
+ //
+ // Key order mirrors catalog.ExtractProductImages (raw_v1.go) and
+ // productImageUrls (apps/web/src/lib/product-images.ts): main_image is often an
+ // empty string while `moreimages` / `additional_image_urls` hold a comma-joined
+ // list, so those are split on the first entry. `%s` is the mapped_data column ref.
+ listMainImageTemplate = `COALESCE(
+ NULLIF(trim(%[1]s->>'image_url'), ''),
+ NULLIF(trim(%[1]s->>'main_image'), ''),
+ NULLIF(trim(%[1]s->>'image_link'), ''),
+ NULLIF(trim(%[1]s->>'mainImage'), ''),
+ NULLIF(trim(%[1]s->>'imageUrl'), ''),
+ NULLIF(trim(%[1]s->'images'->>0), ''),
+ NULLIF(trim(split_part(%[1]s->>'moreimages', ',', 1)), ''),
+ NULLIF(trim(split_part(%[1]s->>'more_images', ',', 1)), ''),
+ NULLIF(trim(split_part(%[1]s->>'additional_image_urls', ',', 1)), ''),
+ NULLIF(trim(split_part(%[1]s->>'additional_image_link', ',', 1)), '')
+ )`
)
+// listMainImageSQL renders listMainImageTemplate for a mapped_data column reference
+// ("rp.mapped_data" on the raw list, "r.mapped_data" on the processed join).
+func listMainImageSQL(mappedDataRef string) string {
+ return fmt.Sprintf(listMainImageTemplate, mappedDataRef)
+}
+
func normalizeCoverageFilter(raw string) string {
c := strings.ToLower(strings.TrimSpace(raw))
c = strings.ReplaceAll(c, "-", "_")
@@ -958,6 +984,7 @@ func (s *Service) ListRawProducts(ctx context.Context, companyID uuid.UUID, f Li
(COALESCE(NULLIF(trim(rp.mapped_data->>'description'), ''), '') <> '') AS has_description,
(COALESCE(NULLIF(trim(rp.mapped_data->>'category'), ''), '') <> '' AND lower(trim(rp.mapped_data->>'category')) <> 'none') AS has_category,
`+strings.ReplaceAll(processedHasFeedAttributesSQL, "r.mapped_data", "rp.mapped_data")+` AS has_attributes,
+ `+listMainImageSQL("rp.mapped_data")+` AS main_image,
rp.created_at, rp.updated_at
%s%s WHERE %s
ORDER BY %s
@@ -971,6 +998,7 @@ func (s *Service) ListRawProducts(ctx context.Context, companyID uuid.UUID, f Li
"name", "category", "category_name", "category_unique_id",
"feed_name", "sync_changes",
"has_name", "has_description", "has_category", "has_attributes",
+ "main_image",
"created_at", "updated_at",
})
},
@@ -1113,6 +1141,7 @@ func (s *Service) ListProcessedProducts(ctx context.Context, companyID uuid.UUID
`+processedHasAttributesSQL+` AS has_attributes,
`+processedHasProcessedAttributesSQL+` AS has_processed_attributes,
`+processedHasEprelSQL+` AS has_eprel,
+ `+listMainImageSQL("r.mapped_data")+` AS main_image,
p.created_at, p.updated_at
FROM processed_products p
LEFT JOIN raw_products r ON r.id = p.raw_product_id
@@ -1129,6 +1158,7 @@ func (s *Service) ListProcessedProducts(ctx context.Context, companyID uuid.UUID
"feed_name", "feed_last_synced_at", "raw_updated_at",
"has_name", "has_processed_name", "has_description", "has_processed_description", "has_category", "has_attributes", "has_processed_attributes",
"has_eprel",
+ "main_image",
"created_at", "updated_at",
})
if err != nil {
diff --git a/apps/web/src/lib/components/DashboardStats.svelte b/apps/web/src/lib/components/DashboardStats.svelte
index 3a72edf..dc1faa5 100644
--- a/apps/web/src/lib/components/DashboardStats.svelte
+++ b/apps/web/src/lib/components/DashboardStats.svelte
@@ -8,6 +8,8 @@
} from "$lib/billing-display";
import { i18n } from "$lib/i18n";
import { FolderTree, ListTree, Package, Rss, Sparkles } from "@lucide/svelte";
+ import { planCapabilities } from "$lib/plan-capabilities.svelte";
+ import { featureKeyForHref } from "$lib/plan-capabilities";
let {
stats,
@@ -89,7 +91,12 @@
icon: Rss,
tour: "stats-feeds"
}
- ]);
+ // Every tile is a link, so hide the ones this member cannot open — a plan gate
+ // or the owner's per-member overlay (settings > team) can deny any of them.
+ ].filter((card) => {
+ const key = featureKeyForHref(card.href);
+ return !key || planCapabilities.can(key);
+ }));
- {#if imageUrls.length > 0}
-
- {i18n.t("products.edit.images")}
-
- {#each imageUrls as src, i}
-
-
-
- {/each}
-
-
- {/if}
+
diff --git a/apps/web/src/lib/components/products/ProductImageGallery.svelte b/apps/web/src/lib/components/products/ProductImageGallery.svelte
new file mode 100644
index 0000000..f14d949
--- /dev/null
+++ b/apps/web/src/lib/components/products/ProductImageGallery.svelte
@@ -0,0 +1,263 @@
+
+
+
+
+{#if images.length > 0}
+
+
+
{i18n.t("products.edit.images")}
+
+ {i18n.t("products.edit.imageCount", { count: images.length })}
+
+
+
+ {#each images as src, i (src)}
+
+ {/each}
+
+
+{/if}
+
+{#if open && current}
+
+
+
+
+
+ {i18n.t("products.edit.imageOf", {
+ index: (openIndex ?? 0) + 1,
+ total: images.length
+ })}
+ {current}
+
+
+
+
+
+
+
+
+ {#if images.length > 1}
+
+ {/if}
+
+ {#if broken.has(current)}
+
+
+
{i18n.t("products.edit.imageFailed")}
+
+ {:else}
+

markBroken(current)}
+ />
+ {/if}
+
+ {#if images.length > 1}
+
+ {/if}
+
+
+ {#if images.length > 1}
+
+ {#each images as src, i (src)}
+
+ {/each}
+
+ {/if}
+
+{/if}
diff --git a/apps/web/src/lib/components/products/ProductTable.svelte b/apps/web/src/lib/components/products/ProductTable.svelte
index db754ad..0fd98e6 100644
--- a/apps/web/src/lib/components/products/ProductTable.svelte
+++ b/apps/web/src/lib/components/products/ProductTable.svelte
@@ -6,6 +6,7 @@ import { Check, Edit, MoreHorizontal, X } from "@lucide/svelte";
import VirtualList from "$lib/components/VirtualList.svelte";
import { cn } from "$lib/utils";
import ProductStatusBadge from "./ProductStatusBadge.svelte";
+ import ProductThumb from "./ProductThumb.svelte";
import {
categoryDisplayName,
enrichmentChipClass,
@@ -21,6 +22,7 @@ import { Check, Edit, MoreHorizontal, X } from "@lucide/svelte";
productLeanIssueLabels,
productLeanIssues,
productSku,
+ productThumbUrl,
type CategoryOption,
type EnrichmentPiece,
type ProductRow
@@ -530,7 +532,13 @@ import { Check, Edit, MoreHorizontal, X } from "@lucide/svelte";
/>
-
+
+
+
{productSku(product)}
{#if editingId !== null && String(editingId) === String(product.id)}
@@ -639,6 +647,7 @@ import { Check, Edit, MoreHorizontal, X } from "@lucide/svelte";
{/if}
+
diff --git a/apps/web/src/lib/components/products/ProductThumb.svelte b/apps/web/src/lib/components/products/ProductThumb.svelte
new file mode 100644
index 0000000..d0e3070
--- /dev/null
+++ b/apps/web/src/lib/components/products/ProductThumb.svelte
@@ -0,0 +1,57 @@
+
+
+
+ {#if showImage}
+

(failed = true)}
+ />
+ {:else}
+
+ {/if}
+
diff --git a/apps/web/src/lib/components/products/types.ts b/apps/web/src/lib/components/products/types.ts
index e0d6531..040ec75 100644
--- a/apps/web/src/lib/components/products/types.ts
+++ b/apps/web/src/lib/components/products/types.ts
@@ -7,7 +7,8 @@ export type {
export {
coerceImageUrl,
coerceImageUrlList,
- productImageUrls
+ productImageUrls,
+ productThumbUrl
} from "../../product-images";
export {
hasExplicitProductTab,
@@ -75,6 +76,8 @@ export type ProductRow = {
attributes?: unknown;
processed_attributes?: unknown;
mapped_data?: unknown;
+ /** Precomputed first image URL on lean list rows (see catalog.listMainImageSQL). */
+ main_image?: string | null;
has_name?: boolean | null;
has_processed_name?: boolean | null;
has_description?: boolean | null;
diff --git a/apps/web/src/lib/components/settings/MemberPermissionsDialog.svelte b/apps/web/src/lib/components/settings/MemberPermissionsDialog.svelte
index 5f6d463..dad9754 100644
--- a/apps/web/src/lib/components/settings/MemberPermissionsDialog.svelte
+++ b/apps/web/src/lib/components/settings/MemberPermissionsDialog.svelte
@@ -6,12 +6,18 @@
import { sectionLabel } from "$lib/plan-feature-catalog";
import { Badge, Button, Checkbox, Dialog, Input, Spinner } from "$lib/components/ui";
import Alert from "$lib/components/Alert.svelte";
+ import { ChevronDown, ChevronRight, Check } from "@lucide/svelte";
import {
+ CUSTOM_ROLE_ID,
+ PERMISSION_ROLES,
catalogKeys,
denyAll,
+ detectRole,
filterCatalog,
isDenied,
permissionSummary,
+ roleById,
+ rolePresetDenied,
samePermissions,
setPermissionAllowed,
type MemberPermissionsView,
@@ -40,11 +46,28 @@
let loadError = $state
(null);
let search = $state("");
let isOwnerMember = $state(false);
+ /** Raw per-key checkboxes stay collapsed — the role picker is the primary control. */
+ let advancedOpen = $state(false);
const allKeys = $derived(catalogKeys(catalog));
const summary = $derived(permissionSummary(catalog, denied));
const dirty = $derived(!samePermissions(denied, savedDenied));
const readOnly = $derived(!canEdit || isOwnerMember);
+ /** Which preset the current selection matches, or "custom" after fine-tuning. */
+ const activeRole = $derived(detectRole(catalog, denied));
+
+ function roleLabel(id: string): string {
+ return i18n.t(`settings.permissions.role.${id}`);
+ }
+
+ function roleDescription(id: string): string {
+ return i18n.t(`settings.permissions.role.${id}.desc`);
+ }
+
+ function applyRole(id: string) {
+ if (readOnly || id === CUSTOM_ROLE_ID) return;
+ denied = rolePresetDenied(catalog, roleById(id));
+ }
function labelFor(key: string): string {
const translated = i18n.t(`plan.feature.${key}`);
@@ -62,6 +85,7 @@
loading = true;
loadError = null;
search = "";
+ advancedOpen = false;
void (async () => {
try {
const [catalogPayload, view] = await Promise.all([
@@ -147,76 +171,137 @@
{/if}
-
-
- {i18n.t("settings.permissions.summary", {
- allowed: summary.allowed,
- total: summary.total
- })}
+
-
-
-
-
- {#if visibleSections.length === 0}
-
- {i18n.t("settings.permissions.noMatches")}
+
+ {#each PERMISSION_ROLES as role (role.id)}
+ {@const selected = activeRole === role.id}
+
+ {/each}
+
+ {#if activeRole === CUSTOM_ROLE_ID}
+
+ {roleLabel(CUSTOM_ROLE_ID)}
+ — {roleDescription(CUSTOM_ROLE_ID)}
{/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}
+
+
+
+
+
+ {#if advancedOpen}
+
+ {#if !readOnly}
+
+
+
- {/each}
-
- {/each}
+ {/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}
{/if}
diff --git a/apps/web/src/lib/i18n/messages/de.ts b/apps/web/src/lib/i18n/messages/de.ts
index 46db7ca..5027504 100644
--- a/apps/web/src/lib/i18n/messages/de.ts
+++ b/apps/web/src/lib/i18n/messages/de.ts
@@ -5678,5 +5678,32 @@ export const de: MessageDict = {
"settings.permissions.loadFailed": "Berechtigungen konnten nicht geladen werden",
"settings.permissions.restrictedBadge": "Eingeschränkt",
"settings.permissions.restrictedHint": "{count} Bereiche deaktiviert",
- "settings.tabLockedByAdmin": "Vom Unternehmensinhaber deaktiviert"
+ "settings.tabLockedByAdmin": "Vom Unternehmensinhaber deaktiviert",
+ "products.table.noImage": "Kein Bild",
+ "products.edit.imageCount": "{count} Bilder",
+ "products.edit.openImage": "Vollbild öffnen",
+ "products.edit.openImageAt": "Bild {index} im Vollbild öffnen",
+ "products.edit.openImageNewTab": "Original in neuem Tab öffnen",
+ "products.edit.galleryLabel": "Bilder von {name}",
+ "products.edit.imageOf": "Bild {index} von {total}",
+ "products.edit.previousImage": "Vorheriges Bild",
+ "products.edit.nextImage": "Nächstes Bild",
+ "products.edit.imageFailed": "Dieses Bild konnte nicht vom Lieferanten geladen werden.",
+ "settings.permissions.roleHeading": "Rolle",
+ "settings.permissions.roleHint": "Wählen Sie eine Rolle — die passenden Bereiche werden automatisch gesetzt. Unten können Sie nachjustieren.",
+ "settings.permissions.role.full": "Voller Zugriff",
+ "settings.permissions.role.full.desc": "Alles, was Ihr Tarif enthält.",
+ "settings.permissions.role.manager": "Manager",
+ "settings.permissions.role.manager.desc": "Alles außer Abrechnung, Team und API-Schlüsseln.",
+ "settings.permissions.role.catalog_editor": "Katalogpflege",
+ "settings.permissions.role.catalog_editor.desc": "Produkte, Kategorien, Attribute und Felder. Keine Feeds, Shops oder Einstellungen.",
+ "settings.permissions.role.feed_operator": "Feed-Betrieb",
+ "settings.permissions.role.feed_operator.desc": "Feeds, Mapping, Exporte und Jobs, dazu der Katalog.",
+ "settings.permissions.role.marketing": "Marketing",
+ "settings.permissions.role.marketing.desc": "Kampagnen, Kalender, SEO, Marke und Bewertungen, dazu der Katalog.",
+ "settings.permissions.role.viewer": "Nur Ansicht",
+ "settings.permissions.role.viewer.desc": "Dashboard, Katalog und Feeds ansehen. Sonst nichts.",
+ "settings.permissions.role.custom": "Benutzerdefiniert",
+ "settings.permissions.role.custom.desc": "Sie haben Bereiche einzeln gewählt — das entspricht keiner Standardrolle.",
+ "settings.permissions.advanced": "Einzelne Bereiche feinjustieren"
};
diff --git a/apps/web/src/lib/i18n/messages/en.ts b/apps/web/src/lib/i18n/messages/en.ts
index 6d946fa..f3048ac 100644
--- a/apps/web/src/lib/i18n/messages/en.ts
+++ b/apps/web/src/lib/i18n/messages/en.ts
@@ -5764,5 +5764,32 @@ export const en: MessageDict = {
"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"
+ "settings.tabLockedByAdmin": "Turned off by your company owner",
+ "products.table.noImage": "No image",
+ "products.edit.imageCount": "{count} images",
+ "products.edit.openImage": "Open full screen",
+ "products.edit.openImageAt": "Open image {index} full screen",
+ "products.edit.openImageNewTab": "Open original in a new tab",
+ "products.edit.galleryLabel": "Images for {name}",
+ "products.edit.imageOf": "Image {index} of {total}",
+ "products.edit.previousImage": "Previous image",
+ "products.edit.nextImage": "Next image",
+ "products.edit.imageFailed": "This image could not be loaded from the supplier.",
+ "settings.permissions.roleHeading": "Role",
+ "settings.permissions.roleHint": "Pick a role and the right areas are selected for you. Fine-tune below if you need to.",
+ "settings.permissions.role.full": "Full access",
+ "settings.permissions.role.full.desc": "Everything your plan includes.",
+ "settings.permissions.role.manager": "Manager",
+ "settings.permissions.role.manager.desc": "Everything except billing, teammates and API keys.",
+ "settings.permissions.role.catalog_editor": "Catalog editor",
+ "settings.permissions.role.catalog_editor.desc": "Products, categories, attributes and fields. No feeds, stores or settings.",
+ "settings.permissions.role.feed_operator": "Feed operator",
+ "settings.permissions.role.feed_operator.desc": "Feeds, mapping, exports and processing jobs, plus the catalog.",
+ "settings.permissions.role.marketing": "Marketing",
+ "settings.permissions.role.marketing.desc": "Campaigns, calendar, SEO, brand and reviews, plus the catalog.",
+ "settings.permissions.role.viewer": "Viewer",
+ "settings.permissions.role.viewer.desc": "Browse the dashboard, catalog and feeds. Nothing else.",
+ "settings.permissions.role.custom": "Custom",
+ "settings.permissions.role.custom.desc": "You picked areas by hand, so this matches no standard role.",
+ "settings.permissions.advanced": "Fine-tune individual areas"
};
diff --git a/apps/web/src/lib/i18n/messages/es.ts b/apps/web/src/lib/i18n/messages/es.ts
index 103c280..2c8bc0f 100644
--- a/apps/web/src/lib/i18n/messages/es.ts
+++ b/apps/web/src/lib/i18n/messages/es.ts
@@ -5687,5 +5687,32 @@ export const es: MessageDict = {
"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"
+ "settings.tabLockedByAdmin": "Desactivado por el propietario de tu empresa",
+ "products.table.noImage": "Sin imagen",
+ "products.edit.imageCount": "{count} imágenes",
+ "products.edit.openImage": "Abrir en pantalla completa",
+ "products.edit.openImageAt": "Abrir la imagen {index} en pantalla completa",
+ "products.edit.openImageNewTab": "Abrir el original en una pestaña nueva",
+ "products.edit.galleryLabel": "Imágenes de {name}",
+ "products.edit.imageOf": "Imagen {index} de {total}",
+ "products.edit.previousImage": "Imagen anterior",
+ "products.edit.nextImage": "Imagen siguiente",
+ "products.edit.imageFailed": "No se pudo cargar esta imagen desde el proveedor.",
+ "settings.permissions.roleHeading": "Rol",
+ "settings.permissions.roleHint": "Elige un rol y se seleccionarán las secciones adecuadas. Ajústalo abajo si lo necesitas.",
+ "settings.permissions.role.full": "Acceso completo",
+ "settings.permissions.role.full.desc": "Todo lo que incluye tu plan.",
+ "settings.permissions.role.manager": "Responsable",
+ "settings.permissions.role.manager.desc": "Todo excepto facturación, equipo y claves API.",
+ "settings.permissions.role.catalog_editor": "Editor de catálogo",
+ "settings.permissions.role.catalog_editor.desc": "Productos, categorías, atributos y campos. Sin feeds, tiendas ni ajustes.",
+ "settings.permissions.role.feed_operator": "Operador de feeds",
+ "settings.permissions.role.feed_operator.desc": "Feeds, mapeo, exportaciones y trabajos, además del catálogo.",
+ "settings.permissions.role.marketing": "Marketing",
+ "settings.permissions.role.marketing.desc": "Campañas, calendario, SEO, marca y reseñas, además del catálogo.",
+ "settings.permissions.role.viewer": "Solo consulta",
+ "settings.permissions.role.viewer.desc": "Ver el panel, el catálogo y los feeds. Nada más.",
+ "settings.permissions.role.custom": "Personalizado",
+ "settings.permissions.role.custom.desc": "Has elegido las secciones a mano, así que no coincide con ningún rol estándar.",
+ "settings.permissions.advanced": "Ajustar secciones concretas"
};
diff --git a/apps/web/src/lib/i18n/messages/fr.ts b/apps/web/src/lib/i18n/messages/fr.ts
index 1dba6d2..843de24 100644
--- a/apps/web/src/lib/i18n/messages/fr.ts
+++ b/apps/web/src/lib/i18n/messages/fr.ts
@@ -5687,5 +5687,32 @@ export const fr: MessageDict = {
"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"
+ "settings.tabLockedByAdmin": "Désactivé par le propriétaire de votre entreprise",
+ "products.table.noImage": "Pas d’image",
+ "products.edit.imageCount": "{count} images",
+ "products.edit.openImage": "Ouvrir en plein écran",
+ "products.edit.openImageAt": "Ouvrir l’image {index} en plein écran",
+ "products.edit.openImageNewTab": "Ouvrir l’original dans un nouvel onglet",
+ "products.edit.galleryLabel": "Images de {name}",
+ "products.edit.imageOf": "Image {index} sur {total}",
+ "products.edit.previousImage": "Image précédente",
+ "products.edit.nextImage": "Image suivante",
+ "products.edit.imageFailed": "Impossible de charger cette image depuis le fournisseur.",
+ "settings.permissions.roleHeading": "Rôle",
+ "settings.permissions.roleHint": "Choisissez un rôle et les sections adaptées sont sélectionnées. Ajustez ci-dessous si besoin.",
+ "settings.permissions.role.full": "Accès complet",
+ "settings.permissions.role.full.desc": "Tout ce que votre forfait inclut.",
+ "settings.permissions.role.manager": "Responsable",
+ "settings.permissions.role.manager.desc": "Tout sauf la facturation, l’équipe et les clés API.",
+ "settings.permissions.role.catalog_editor": "Éditeur de catalogue",
+ "settings.permissions.role.catalog_editor.desc": "Produits, catégories, attributs et champs. Ni flux, ni boutiques, ni réglages.",
+ "settings.permissions.role.feed_operator": "Opérateur de flux",
+ "settings.permissions.role.feed_operator.desc": "Flux, mapping, exports et traitements, plus le catalogue.",
+ "settings.permissions.role.marketing": "Marketing",
+ "settings.permissions.role.marketing.desc": "Campagnes, calendrier, SEO, marque et avis, plus le catalogue.",
+ "settings.permissions.role.viewer": "Lecture seule",
+ "settings.permissions.role.viewer.desc": "Consulter le tableau de bord, le catalogue et les flux. Rien d’autre.",
+ "settings.permissions.role.custom": "Personnalisé",
+ "settings.permissions.role.custom.desc": "Vous avez choisi les sections à la main : cela ne correspond à aucun rôle standard.",
+ "settings.permissions.advanced": "Ajuster des sections précises"
};
diff --git a/apps/web/src/lib/i18n/messages/it.ts b/apps/web/src/lib/i18n/messages/it.ts
index 48e6e39..0be9d5c 100644
--- a/apps/web/src/lib/i18n/messages/it.ts
+++ b/apps/web/src/lib/i18n/messages/it.ts
@@ -5687,5 +5687,32 @@ export const it: MessageDict = {
"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"
+ "settings.tabLockedByAdmin": "Disattivato dal proprietario della tua azienda",
+ "products.table.noImage": "Nessuna immagine",
+ "products.edit.imageCount": "{count} immagini",
+ "products.edit.openImage": "Apri a schermo intero",
+ "products.edit.openImageAt": "Apri l’immagine {index} a schermo intero",
+ "products.edit.openImageNewTab": "Apri l’originale in una nuova scheda",
+ "products.edit.galleryLabel": "Immagini di {name}",
+ "products.edit.imageOf": "Immagine {index} di {total}",
+ "products.edit.previousImage": "Immagine precedente",
+ "products.edit.nextImage": "Immagine successiva",
+ "products.edit.imageFailed": "Impossibile caricare questa immagine dal fornitore.",
+ "settings.permissions.roleHeading": "Ruolo",
+ "settings.permissions.roleHint": "Scegli un ruolo e le sezioni giuste vengono selezionate da sole. Puoi rifinire qui sotto.",
+ "settings.permissions.role.full": "Accesso completo",
+ "settings.permissions.role.full.desc": "Tutto ciò che include il tuo piano.",
+ "settings.permissions.role.manager": "Responsabile",
+ "settings.permissions.role.manager.desc": "Tutto tranne fatturazione, team e chiavi API.",
+ "settings.permissions.role.catalog_editor": "Editor del catalogo",
+ "settings.permissions.role.catalog_editor.desc": "Prodotti, categorie, attributi e campi. Niente feed, store o impostazioni.",
+ "settings.permissions.role.feed_operator": "Operatore feed",
+ "settings.permissions.role.feed_operator.desc": "Feed, mappatura, esportazioni e job, più il catalogo.",
+ "settings.permissions.role.marketing": "Marketing",
+ "settings.permissions.role.marketing.desc": "Campagne, calendario, SEO, brand e recensioni, più il catalogo.",
+ "settings.permissions.role.viewer": "Sola consultazione",
+ "settings.permissions.role.viewer.desc": "Consultare dashboard, catalogo e feed. Nient’altro.",
+ "settings.permissions.role.custom": "Personalizzato",
+ "settings.permissions.role.custom.desc": "Hai scelto le sezioni a mano, quindi non corrisponde a nessun ruolo standard.",
+ "settings.permissions.advanced": "Regola le singole sezioni"
};
diff --git a/apps/web/src/lib/i18n/messages/ja.ts b/apps/web/src/lib/i18n/messages/ja.ts
index 301915a..9127cdc 100644
--- a/apps/web/src/lib/i18n/messages/ja.ts
+++ b/apps/web/src/lib/i18n/messages/ja.ts
@@ -5687,5 +5687,32 @@ export const ja: MessageDict = {
"settings.permissions.loadFailed": "権限を読み込めませんでした",
"settings.permissions.restrictedBadge": "制限あり",
"settings.permissions.restrictedHint": "{count} 件のエリアを無効化",
- "settings.tabLockedByAdmin": "会社のオーナーが無効にしています"
+ "settings.tabLockedByAdmin": "会社のオーナーが無効にしています",
+ "products.table.noImage": "画像なし",
+ "products.edit.imageCount": "画像 {count} 件",
+ "products.edit.openImage": "全画面で開く",
+ "products.edit.openImageAt": "{index} 番目の画像を全画面で開く",
+ "products.edit.openImageNewTab": "元画像を新しいタブで開く",
+ "products.edit.galleryLabel": "{name} の画像",
+ "products.edit.imageOf": "{total} 件中 {index} 件目",
+ "products.edit.previousImage": "前の画像",
+ "products.edit.nextImage": "次の画像",
+ "products.edit.imageFailed": "仕入先からこの画像を読み込めませんでした。",
+ "settings.permissions.roleHeading": "ロール",
+ "settings.permissions.roleHint": "ロールを選ぶと必要なエリアが自動で選択されます。下で微調整もできます。",
+ "settings.permissions.role.full": "フルアクセス",
+ "settings.permissions.role.full.desc": "プランに含まれるすべて。",
+ "settings.permissions.role.manager": "マネージャー",
+ "settings.permissions.role.manager.desc": "請求・メンバー・API キー以外のすべて。",
+ "settings.permissions.role.catalog_editor": "カタログ編集",
+ "settings.permissions.role.catalog_editor.desc": "商品・カテゴリ・属性・項目。フィードやストア、設定は不可。",
+ "settings.permissions.role.feed_operator": "フィード担当",
+ "settings.permissions.role.feed_operator.desc": "フィード、マッピング、エクスポート、ジョブとカタログ。",
+ "settings.permissions.role.marketing": "マーケティング",
+ "settings.permissions.role.marketing.desc": "キャンペーン、カレンダー、SEO、ブランド、レビューとカタログ。",
+ "settings.permissions.role.viewer": "閲覧のみ",
+ "settings.permissions.role.viewer.desc": "ダッシュボード、カタログ、フィードの閲覧のみ。",
+ "settings.permissions.role.custom": "カスタム",
+ "settings.permissions.role.custom.desc": "エリアを個別に選択したため、標準ロールには一致しません。",
+ "settings.permissions.advanced": "エリアごとに細かく調整"
};
diff --git a/apps/web/src/lib/i18n/messages/nl.ts b/apps/web/src/lib/i18n/messages/nl.ts
index 12be630..87e8452 100644
--- a/apps/web/src/lib/i18n/messages/nl.ts
+++ b/apps/web/src/lib/i18n/messages/nl.ts
@@ -5687,5 +5687,32 @@ export const nl: MessageDict = {
"settings.permissions.loadFailed": "Rechten konden niet worden geladen",
"settings.permissions.restrictedBadge": "Beperkt",
"settings.permissions.restrictedHint": "{count} onderdelen uitgeschakeld",
- "settings.tabLockedByAdmin": "Uitgeschakeld door de bedrijfseigenaar"
+ "settings.tabLockedByAdmin": "Uitgeschakeld door de bedrijfseigenaar",
+ "products.table.noImage": "Geen afbeelding",
+ "products.edit.imageCount": "{count} afbeeldingen",
+ "products.edit.openImage": "Schermvullend openen",
+ "products.edit.openImageAt": "Afbeelding {index} schermvullend openen",
+ "products.edit.openImageNewTab": "Origineel in een nieuw tabblad openen",
+ "products.edit.galleryLabel": "Afbeeldingen van {name}",
+ "products.edit.imageOf": "Afbeelding {index} van {total}",
+ "products.edit.previousImage": "Vorige afbeelding",
+ "products.edit.nextImage": "Volgende afbeelding",
+ "products.edit.imageFailed": "Deze afbeelding kon niet bij de leverancier worden opgehaald.",
+ "settings.permissions.roleHeading": "Rol",
+ "settings.permissions.roleHint": "Kies een rol; de juiste onderdelen worden vanzelf aangevinkt. Hieronder kun je bijstellen.",
+ "settings.permissions.role.full": "Volledige toegang",
+ "settings.permissions.role.full.desc": "Alles wat je abonnement bevat.",
+ "settings.permissions.role.manager": "Manager",
+ "settings.permissions.role.manager.desc": "Alles behalve facturatie, teamleden en API-sleutels.",
+ "settings.permissions.role.catalog_editor": "Catalogusbeheer",
+ "settings.permissions.role.catalog_editor.desc": "Producten, categorieën, kenmerken en velden. Geen feeds, winkels of instellingen.",
+ "settings.permissions.role.feed_operator": "Feedbeheer",
+ "settings.permissions.role.feed_operator.desc": "Feeds, mapping, exports en taken, plus de catalogus.",
+ "settings.permissions.role.marketing": "Marketing",
+ "settings.permissions.role.marketing.desc": "Campagnes, kalender, SEO, merk en reviews, plus de catalogus.",
+ "settings.permissions.role.viewer": "Alleen bekijken",
+ "settings.permissions.role.viewer.desc": "Dashboard, catalogus en feeds bekijken. Verder niets.",
+ "settings.permissions.role.custom": "Aangepast",
+ "settings.permissions.role.custom.desc": "Je hebt onderdelen handmatig gekozen, dus dit komt met geen enkele standaardrol overeen.",
+ "settings.permissions.advanced": "Losse onderdelen bijstellen"
};
diff --git a/apps/web/src/lib/i18n/messages/pl.ts b/apps/web/src/lib/i18n/messages/pl.ts
index 07d712d..3f692e2 100644
--- a/apps/web/src/lib/i18n/messages/pl.ts
+++ b/apps/web/src/lib/i18n/messages/pl.ts
@@ -5687,5 +5687,32 @@ export const pl: MessageDict = {
"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"
+ "settings.tabLockedByAdmin": "Wyłączone przez właściciela firmy",
+ "products.table.noImage": "Brak zdjęcia",
+ "products.edit.imageCount": "Zdjęcia: {count}",
+ "products.edit.openImage": "Otwórz na pełnym ekranie",
+ "products.edit.openImageAt": "Otwórz zdjęcie {index} na pełnym ekranie",
+ "products.edit.openImageNewTab": "Otwórz oryginał w nowej karcie",
+ "products.edit.galleryLabel": "Zdjęcia produktu {name}",
+ "products.edit.imageOf": "Zdjęcie {index} z {total}",
+ "products.edit.previousImage": "Poprzednie zdjęcie",
+ "products.edit.nextImage": "Następne zdjęcie",
+ "products.edit.imageFailed": "Nie udało się pobrać tego zdjęcia od dostawcy.",
+ "settings.permissions.roleHeading": "Rola",
+ "settings.permissions.roleHint": "Wybierz rolę, a właściwe sekcje zaznaczą się same. Poniżej możesz je doprecyzować.",
+ "settings.permissions.role.full": "Pełny dostęp",
+ "settings.permissions.role.full.desc": "Wszystko, co obejmuje Twój plan.",
+ "settings.permissions.role.manager": "Menedżer",
+ "settings.permissions.role.manager.desc": "Wszystko poza płatnościami, zespołem i kluczami API.",
+ "settings.permissions.role.catalog_editor": "Redaktor katalogu",
+ "settings.permissions.role.catalog_editor.desc": "Produkty, kategorie, atrybuty i pola. Bez feedów, sklepów i ustawień.",
+ "settings.permissions.role.feed_operator": "Operator feedów",
+ "settings.permissions.role.feed_operator.desc": "Feedy, mapowanie, eksporty i zadania oraz katalog.",
+ "settings.permissions.role.marketing": "Marketing",
+ "settings.permissions.role.marketing.desc": "Kampanie, kalendarz, SEO, marka i opinie oraz katalog.",
+ "settings.permissions.role.viewer": "Tylko podgląd",
+ "settings.permissions.role.viewer.desc": "Przeglądanie pulpitu, katalogu i feedów. Nic więcej.",
+ "settings.permissions.role.custom": "Własna",
+ "settings.permissions.role.custom.desc": "Sekcje wybrano ręcznie, więc nie odpowiada to żadnej standardowej roli.",
+ "settings.permissions.advanced": "Dostosuj pojedyncze sekcje"
};
diff --git a/apps/web/src/lib/i18n/messages/pt.ts b/apps/web/src/lib/i18n/messages/pt.ts
index f1bfe5f..1bfd473 100644
--- a/apps/web/src/lib/i18n/messages/pt.ts
+++ b/apps/web/src/lib/i18n/messages/pt.ts
@@ -5687,5 +5687,32 @@ export const pt: MessageDict = {
"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"
+ "settings.tabLockedByAdmin": "Desativado pelo proprietário da sua empresa",
+ "products.table.noImage": "Sem imagem",
+ "products.edit.imageCount": "{count} imagens",
+ "products.edit.openImage": "Abrir em ecrã inteiro",
+ "products.edit.openImageAt": "Abrir a imagem {index} em ecrã inteiro",
+ "products.edit.openImageNewTab": "Abrir o original num novo separador",
+ "products.edit.galleryLabel": "Imagens de {name}",
+ "products.edit.imageOf": "Imagem {index} de {total}",
+ "products.edit.previousImage": "Imagem anterior",
+ "products.edit.nextImage": "Imagem seguinte",
+ "products.edit.imageFailed": "Não foi possível carregar esta imagem do fornecedor.",
+ "settings.permissions.roleHeading": "Função",
+ "settings.permissions.roleHint": "Escolha uma função e as áreas certas são selecionadas. Pode ajustar em baixo.",
+ "settings.permissions.role.full": "Acesso total",
+ "settings.permissions.role.full.desc": "Tudo o que o seu plano inclui.",
+ "settings.permissions.role.manager": "Gestor",
+ "settings.permissions.role.manager.desc": "Tudo exceto faturação, equipa e chaves de API.",
+ "settings.permissions.role.catalog_editor": "Editor de catálogo",
+ "settings.permissions.role.catalog_editor.desc": "Produtos, categorias, atributos e campos. Sem feeds, lojas ou definições.",
+ "settings.permissions.role.feed_operator": "Operador de feeds",
+ "settings.permissions.role.feed_operator.desc": "Feeds, mapeamento, exportações e tarefas, mais o catálogo.",
+ "settings.permissions.role.marketing": "Marketing",
+ "settings.permissions.role.marketing.desc": "Campanhas, calendário, SEO, marca e avaliações, mais o catálogo.",
+ "settings.permissions.role.viewer": "Consulta",
+ "settings.permissions.role.viewer.desc": "Ver o painel, o catálogo e os feeds. Mais nada.",
+ "settings.permissions.role.custom": "Personalizado",
+ "settings.permissions.role.custom.desc": "Escolheu as áreas manualmente, por isso não corresponde a nenhuma função padrão.",
+ "settings.permissions.advanced": "Ajustar áreas individuais"
};
diff --git a/apps/web/src/lib/member-permissions.test.ts b/apps/web/src/lib/member-permissions.test.ts
index 2ec529a..65bf81c 100644
--- a/apps/web/src/lib/member-permissions.test.ts
+++ b/apps/web/src/lib/member-permissions.test.ts
@@ -9,6 +9,11 @@ import {
permissionSummary,
samePermissions,
setPermissionAllowed,
+ CUSTOM_ROLE_ID,
+ PERMISSION_ROLES,
+ detectRole,
+ roleById,
+ rolePresetDenied,
type PermissionCatalog
} from "./member-permissions.ts";
@@ -161,3 +166,125 @@ describe("filterCatalog", () => {
);
});
});
+
+
+/** A catalog wide enough to exercise section-level role grants. */
+const roleCatalog: PermissionCatalog = {
+ sections: [
+ {
+ id: "dashboard",
+ entries: [{ key: "dashboard.stats", section: "dashboard", parent: "", plan_allowed: true }]
+ },
+ {
+ 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.categories", section: "catalog", parent: "", plan_allowed: true }
+ ]
+ },
+ {
+ id: "feeds",
+ entries: [
+ { key: "feeds.list", section: "feeds", parent: "", plan_allowed: true },
+ { key: "feeds.export_feeds", section: "feeds", parent: "", plan_allowed: true }
+ ]
+ },
+ {
+ id: "marketing",
+ entries: [{ key: "marketing.campaigns", section: "marketing", parent: "", plan_allowed: true }]
+ },
+ {
+ id: "integrations",
+ entries: [{ key: "integrations.email", section: "integrations", parent: "", plan_allowed: true }]
+ },
+ {
+ id: "billing",
+ entries: [
+ { key: "billing.overview", section: "billing", parent: "", plan_allowed: true },
+ { key: "billing.checkout", section: "billing", parent: "", plan_allowed: true }
+ ]
+ },
+ {
+ id: "settings",
+ entries: [
+ { key: "settings.company", section: "settings", parent: "", plan_allowed: true },
+ { key: "settings.api_keys", section: "settings", parent: "", plan_allowed: true },
+ { key: "settings.team", section: "settings", parent: "", plan_allowed: true }
+ ]
+ },
+ {
+ id: "support",
+ entries: [{ key: "support.center", section: "support", parent: "", plan_allowed: true }]
+ }
+ ]
+};
+
+describe("rolePresetDenied", () => {
+ it("full access denies nothing", () => {
+ assert.deepEqual(rolePresetDenied(roleCatalog, roleById("full")), []);
+ });
+
+ it("manager keeps the operation but not money, team or API keys", () => {
+ const denied = rolePresetDenied(roleCatalog, roleById("manager"));
+ assert.deepEqual(denied, ["billing.checkout", "settings.api_keys", "settings.team"]);
+ // Everything else in those sections stays open.
+ assert.equal(isDenied(denied, "billing.overview"), false);
+ assert.equal(isDenied(denied, "settings.company"), false);
+ assert.equal(isDenied(denied, "marketing.campaigns"), false);
+ });
+
+ it("catalog editor grants only dashboard, catalog and support", () => {
+ const denied = rolePresetDenied(roleCatalog, roleById("catalog_editor"));
+ assert.equal(isDenied(denied, "catalog.products"), false);
+ assert.equal(isDenied(denied, "catalog.products.tab_error"), false);
+ assert.equal(isDenied(denied, "support.center"), false);
+ assert.equal(isDenied(denied, "feeds.list"), true);
+ assert.equal(isDenied(denied, "marketing.campaigns"), true);
+ assert.equal(isDenied(denied, "settings.api_keys"), true);
+ // Denials collapse to roots — children are implied, never stored.
+ assert.equal(denied.includes("catalog.products.tab_error"), false);
+ });
+
+ it("marketing adds email on top of its sections", () => {
+ const denied = rolePresetDenied(roleCatalog, roleById("marketing"));
+ assert.equal(isDenied(denied, "marketing.campaigns"), false);
+ assert.equal(isDenied(denied, "integrations.email"), false);
+ assert.equal(isDenied(denied, "feeds.list"), true);
+ });
+
+ it("viewer grants browsing only", () => {
+ const denied = rolePresetDenied(roleCatalog, roleById("viewer"));
+ assert.equal(isDenied(denied, "catalog.products"), false);
+ assert.equal(isDenied(denied, "feeds.list"), false);
+ assert.equal(isDenied(denied, "feeds.export_feeds"), true);
+ assert.equal(isDenied(denied, "settings.company"), true);
+ });
+
+ it("returns nothing for an unknown role", () => {
+ assert.deepEqual(rolePresetDenied(roleCatalog, roleById("nope")), []);
+ });
+});
+
+describe("detectRole", () => {
+ it("round-trips every preset", () => {
+ for (const role of PERMISSION_ROLES) {
+ const denied = rolePresetDenied(roleCatalog, role);
+ assert.equal(detectRole(roleCatalog, denied), role.id, `role ${role.id}`);
+ }
+ });
+
+ it("falls back to custom for a hand-tuned selection", () => {
+ const denied = setPermissionAllowed([], "catalog.categories", false, catalogKeys(roleCatalog));
+ assert.equal(detectRole(roleCatalog, denied), CUSTOM_ROLE_ID);
+ });
+
+ it("an empty selection is full access, not custom", () => {
+ assert.equal(detectRole(roleCatalog, []), "full");
+ });
+});
diff --git a/apps/web/src/lib/member-permissions.ts b/apps/web/src/lib/member-permissions.ts
index 11bc509..0d744f4 100644
--- a/apps/web/src/lib/member-permissions.ts
+++ b/apps/web/src/lib/member-permissions.ts
@@ -91,7 +91,7 @@ export function setPermissionAllowed(
if (!allowed) {
for (const descendant of descendantsOf(key, allKeys)) next.delete(descendant);
next.add(key);
- return normalize(next, allKeys);
+ return normalizeDenied(next, allKeys);
}
next.delete(key);
@@ -104,11 +104,11 @@ export function setPermissionAllowed(
if (!openPath.has(sibling)) next.add(sibling);
}
}
- return normalize(next, allKeys);
+ return normalizeDenied(next, allKeys);
}
/** Drop keys outside the catalog and denials already implied by a denied ancestor. */
-function normalize(denied: Set, allKeys: string[]): string[] {
+export function normalizeDenied(denied: Set, allKeys: string[]): string[] {
const known = new Set(allKeys);
const out: string[] = [];
for (const key of denied) {
@@ -122,7 +122,7 @@ function normalize(denied: Set, allKeys: string[]): string[] {
/** 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);
+ return normalizeDenied(new Set(all), all);
}
/** Allowed / total counts for the summary line, ignoring keys the plan already denies. */
@@ -170,3 +170,110 @@ export function filterCatalog(
}
return out;
}
+
+
+/**
+ * Role presets.
+ *
+ * Raw feature keys ("catalog.products.tab_error") are precise but unreadable to most
+ * owners, so the editor leads with a role and derives the checkbox state from it.
+ * A role grants whole dashboard sections, with a few key-level exceptions; anything
+ * not granted is denied, and parent-prefix denial collapses the stored list.
+ *
+ * Roles describe which AREAS a teammate can open — the model is page-level, not
+ * verb-level, so a role never implies "read-only" within an area it grants.
+ */
+export type PermissionRole = {
+ id: string;
+ /** Sections fully granted, or "all" for every section. */
+ sections: "all" | string[];
+ /** Extra keys granted outside the granted sections. */
+ allowKeys?: string[];
+ /** Keys denied even though their section is granted. */
+ denyKeys?: string[];
+};
+
+export const PERMISSION_ROLES: PermissionRole[] = [
+ { id: "full", sections: "all" },
+ {
+ // Runs the whole product operation, but money and account access stay with the owner.
+ id: "manager",
+ sections: "all",
+ denyKeys: [
+ "settings.api_keys",
+ "settings.team",
+ "settings.team_invite",
+ "billing.checkout",
+ "billing.customer_portal",
+ "billing.quick_upgrade"
+ ]
+ },
+ { id: "catalog_editor", sections: ["dashboard", "catalog", "support"] },
+ { id: "feed_operator", sections: ["dashboard", "catalog", "feeds", "processing", "support"] },
+ {
+ id: "marketing",
+ sections: ["dashboard", "catalog", "marketing", "support"],
+ allowKeys: ["integrations.email"]
+ },
+ {
+ id: "viewer",
+ sections: ["dashboard", "support"],
+ allowKeys: [
+ "catalog.products",
+ "catalog.categories",
+ "catalog.attributes",
+ "catalog.standard_fields",
+ "feeds.list"
+ ]
+ }
+];
+
+/** Sentinel returned by detectRole when the selection matches no preset. */
+export const CUSTOM_ROLE_ID = "custom";
+
+export function roleById(id: string): PermissionRole | null {
+ return PERMISSION_ROLES.find((role) => role.id === id) ?? null;
+}
+
+/** Top-level grantable entries (those with no grantable ancestor in the catalog). */
+function rootEntries(catalog: PermissionCatalog | null | undefined): PermissionCatalogEntry[] {
+ return (catalog?.sections ?? []).flatMap((section) =>
+ section.entries.filter((entry) => !entry.parent)
+ );
+}
+
+/** The denied list a role produces for this company's catalog. */
+export function rolePresetDenied(
+ catalog: PermissionCatalog | null | undefined,
+ role: PermissionRole | null
+): string[] {
+ if (!role) return [];
+ const all = catalogKeys(catalog);
+ const allowKeys = new Set(role.allowKeys ?? []);
+ const denyKeys = new Set(role.denyKeys ?? []);
+ const denied = new Set();
+
+ for (const entry of rootEntries(catalog)) {
+ const sectionGranted =
+ role.sections === "all" || role.sections.includes(entry.section);
+ if (denyKeys.has(entry.key) || (!sectionGranted && !allowKeys.has(entry.key))) {
+ denied.add(entry.key);
+ }
+ }
+ // Key-level exceptions below a granted root (e.g. deny settings.api_keys under settings).
+ for (const key of denyKeys) {
+ if (!isDenied(denied, key)) denied.add(key);
+ }
+ return normalizeDenied(denied, all);
+}
+
+/** Which role the current selection corresponds to, or CUSTOM_ROLE_ID. */
+export function detectRole(
+ catalog: PermissionCatalog | null | undefined,
+ denied: string[]
+): string {
+ for (const role of PERMISSION_ROLES) {
+ if (samePermissions(rolePresetDenied(catalog, role), denied)) return role.id;
+ }
+ return CUSTOM_ROLE_ID;
+}
diff --git a/apps/web/src/lib/product-images.ts b/apps/web/src/lib/product-images.ts
index 267f3aa..62928dc 100644
--- a/apps/web/src/lib/product-images.ts
+++ b/apps/web/src/lib/product-images.ts
@@ -139,3 +139,18 @@ export function productImageUrls(product: ProductImageSource): string[] {
}
return out;
}
+
+/**
+ * First usable image URL for a product row, or null.
+ *
+ * The lean product list omits mapped_data and instead ships a precomputed
+ * `main_image` column (catalog.listMainImageSQL); the detailed list and the editor
+ * carry mapped_data, so fall back to the full extractor there.
+ */
+export function productThumbUrl(product: ProductImageSource): string | null {
+ const row = asRecord(product);
+ if (!row) return null;
+ const direct = coerceImageUrl(row.main_image);
+ if (direct) return direct;
+ return productImageUrls(product)[0] ?? null;
+}
diff --git a/apps/web/src/routes/dashboard/+page.svelte b/apps/web/src/routes/dashboard/+page.svelte
index 4895618..f31a199 100644
--- a/apps/web/src/routes/dashboard/+page.svelte
+++ b/apps/web/src/routes/dashboard/+page.svelte
@@ -335,6 +335,12 @@
const canMonitorJobs = $derived(planCapabilities.can("processing.monitor"));
const canExportFeeds = $derived(planCapabilities.can("feeds.export_feeds"));
+ // Feeds / Products used to be assumed always-on. A per-member permission overlay
+ // (settings > team) can now deny either, so the shortcuts and workflow steps must
+ // gate on them too — otherwise the dashboard offers links that dead-end on the
+ // "access restricted" panel.
+ const canBrowseFeeds = $derived(planCapabilities.can("feeds.list"));
+ const canBrowseProducts = $derived(planCapabilities.can("catalog.products"));
const planFlags = $derived({
name: typeof planInfo?.name === "string" ? planInfo.name : "",
is_legacy: typeof planInfo?.is_legacy === "boolean" ? planInfo.is_legacy : undefined,
@@ -419,7 +425,9 @@
done: processedTotal > 0,
tour: "workflow-process"
}
- ]);
+ ].filter((step) =>
+ step.id === "process" ? canBrowseProducts : canBrowseFeeds
+ ));
{#if loading}
@@ -804,6 +812,7 @@
{i18n.t("dashboard.quickLinksHint")}
+ {#if canBrowseFeeds}
+ {/if}
+ {#if canBrowseProducts}
+ {/if}
{#if canMonitorJobs}