fix
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}));
|
||||
</script>
|
||||
|
||||
<div
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
resolveOriginalName
|
||||
} from "./types";
|
||||
import HtmlContent from "./HtmlContent.svelte";
|
||||
import ProductImageGallery from "./ProductImageGallery.svelte";
|
||||
import { looksLikeHtml } from "./html-content";
|
||||
import ContentLanguageSwitcher from "$lib/components/ContentLanguageSwitcher.svelte";
|
||||
import { DEFAULT_CONTENT_LANGUAGE, parseContentLanguage } from "$lib/content-languages";
|
||||
@@ -895,33 +896,10 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{#if imageUrls.length > 0}
|
||||
<section class="space-y-3" data-testid="product-images">
|
||||
<h3 class="text-sm font-medium">{i18n.t("products.edit.images")}</h3>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
{#each imageUrls as src, i}
|
||||
<a
|
||||
href={src}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="block overflow-hidden rounded-md border border-border bg-muted/30"
|
||||
title={src}
|
||||
>
|
||||
<img
|
||||
src={src}
|
||||
alt={i18n.t("products.edit.imageAlt", {
|
||||
index: i + 1,
|
||||
name: product?.processed_name || product?.name || productId
|
||||
})}
|
||||
class="h-28 w-28 object-contain"
|
||||
loading="lazy"
|
||||
referrerpolicy="no-referrer"
|
||||
/>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
<ProductImageGallery
|
||||
images={imageUrls}
|
||||
productName={product?.processed_name || product?.name || productId}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="feed" class="mt-4 space-y-4">
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
<script lang="ts">
|
||||
import { ChevronLeft, ChevronRight, ExternalLink, ImageOff, X } from "@lucide/svelte";
|
||||
import { i18n } from "$lib/i18n";
|
||||
import { cn } from "$lib/utils";
|
||||
import { activateFocusTrap, type FocusTrapHandle } from "$lib/a11y/focus-trap";
|
||||
import { portal } from "$lib/actions/portal";
|
||||
|
||||
let {
|
||||
images,
|
||||
productName = ""
|
||||
}: {
|
||||
/** Image URLs in display order; the first is the main image. */
|
||||
images: string[];
|
||||
productName?: string;
|
||||
} = $props();
|
||||
|
||||
/** Index of the image shown full-screen, or null when the lightbox is closed. */
|
||||
let openIndex = $state<number | null>(null);
|
||||
/** URLs that failed to load — shown as a placeholder tile instead of a torn icon. */
|
||||
let broken = $state<Set<string>>(new Set());
|
||||
let overlayEl = $state<HTMLDivElement | null>(null);
|
||||
let trap: FocusTrapHandle | null = null;
|
||||
|
||||
const open = $derived(openIndex !== null);
|
||||
const current = $derived(openIndex === null ? null : (images[openIndex] ?? null));
|
||||
|
||||
function altFor(index: number): string {
|
||||
return i18n.t("products.edit.imageAlt", { index: index + 1, name: productName });
|
||||
}
|
||||
|
||||
function markBroken(url: string) {
|
||||
if (broken.has(url)) return;
|
||||
broken = new Set([...broken, url]);
|
||||
}
|
||||
|
||||
function show(index: number) {
|
||||
if (images.length === 0) return;
|
||||
// Wrap around so arrow keys never dead-end on the first / last image.
|
||||
openIndex = (index + images.length) % images.length;
|
||||
}
|
||||
|
||||
function close() {
|
||||
openIndex = null;
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
if (openIndex === null) return;
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
close();
|
||||
return;
|
||||
}
|
||||
if (event.key === "ArrowRight") {
|
||||
event.preventDefault();
|
||||
show(openIndex + 1);
|
||||
return;
|
||||
}
|
||||
if (event.key === "ArrowLeft") {
|
||||
event.preventDefault();
|
||||
show(openIndex - 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Lock page scroll and trap focus while the overlay covers the screen.
|
||||
$effect(() => {
|
||||
if (!open || typeof document === "undefined") return;
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.body.style.overflow = previousOverflow;
|
||||
};
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!open || !overlayEl) {
|
||||
trap?.deactivate();
|
||||
trap = null;
|
||||
return;
|
||||
}
|
||||
trap = activateFocusTrap(overlayEl);
|
||||
return () => {
|
||||
trap?.deactivate();
|
||||
trap = null;
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onKeydown} />
|
||||
|
||||
{#if images.length > 0}
|
||||
<section class="space-y-3" data-testid="product-images">
|
||||
<div class="flex items-baseline gap-2">
|
||||
<h3 class="text-sm font-medium">{i18n.t("products.edit.images")}</h3>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{i18n.t("products.edit.imageCount", { count: images.length })}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
{#each images as src, i (src)}
|
||||
<button
|
||||
type="button"
|
||||
class="group relative block overflow-hidden rounded-md border border-border bg-muted/30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
title={i18n.t("products.edit.openImage")}
|
||||
aria-label={i18n.t("products.edit.openImageAt", { index: i + 1 })}
|
||||
data-testid="product-image-thumb"
|
||||
onclick={() => show(i)}
|
||||
>
|
||||
{#if broken.has(src)}
|
||||
<span class="flex h-28 w-28 items-center justify-center">
|
||||
<ImageOff class="h-5 w-5 text-muted-foreground/60" />
|
||||
</span>
|
||||
{:else}
|
||||
<img
|
||||
{src}
|
||||
alt={altFor(i)}
|
||||
class="h-28 w-28 object-contain transition-transform duration-200 group-hover:scale-105"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
referrerpolicy="no-referrer"
|
||||
onerror={() => markBroken(src)}
|
||||
/>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if open && current}
|
||||
<!-- Full-screen viewer. Sits above the edit panel; Escape / backdrop click closes. -->
|
||||
<!--
|
||||
Portalled to <body> and above the sticky app header (z-[110]) so a full-screen
|
||||
image is genuinely full screen instead of sitting under the top bar.
|
||||
-->
|
||||
<div
|
||||
bind:this={overlayEl}
|
||||
use:portal
|
||||
class="fixed inset-0 z-[120] flex flex-col bg-black/95"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={i18n.t("products.edit.galleryLabel", { name: productName })}
|
||||
data-testid="product-image-lightbox"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3 px-4 py-3 text-white">
|
||||
<p class="min-w-0 truncate text-sm" title={current}>
|
||||
{i18n.t("products.edit.imageOf", {
|
||||
index: (openIndex ?? 0) + 1,
|
||||
total: images.length
|
||||
})}
|
||||
<span class="ml-2 text-white/60">{current}</span>
|
||||
</p>
|
||||
<div class="flex shrink-0 items-center gap-1">
|
||||
<a
|
||||
href={current}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="rounded-md p-2 text-white/80 hover:bg-white/10 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/60"
|
||||
aria-label={i18n.t("products.edit.openImageNewTab")}
|
||||
title={i18n.t("products.edit.openImageNewTab")}
|
||||
>
|
||||
<ExternalLink class="h-5 w-5" />
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md p-2 text-white/80 hover:bg-white/10 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/60"
|
||||
aria-label={i18n.t("common.close")}
|
||||
data-testid="product-image-lightbox-close"
|
||||
onclick={close}
|
||||
>
|
||||
<X class="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative flex min-h-0 flex-1 items-center justify-center px-4 pb-4">
|
||||
<!--
|
||||
Backdrop sits behind the image as its own hit target, so clicking beside the
|
||||
image closes while clicking the image itself does nothing (no stopPropagation
|
||||
handler on a non-interactive element).
|
||||
-->
|
||||
<button
|
||||
type="button"
|
||||
class="absolute inset-0 z-0 cursor-default"
|
||||
aria-label={i18n.t("common.close")}
|
||||
tabindex="-1"
|
||||
onclick={close}
|
||||
></button>
|
||||
|
||||
{#if images.length > 1}
|
||||
<button
|
||||
type="button"
|
||||
class="absolute left-2 z-10 rounded-full bg-white/10 p-3 text-white hover:bg-white/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/60 sm:left-6"
|
||||
aria-label={i18n.t("products.edit.previousImage")}
|
||||
data-testid="product-image-prev"
|
||||
onclick={() => show((openIndex ?? 0) - 1)}
|
||||
>
|
||||
<ChevronLeft class="h-6 w-6" />
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if broken.has(current)}
|
||||
<div class="z-[1] flex flex-col items-center gap-2 text-white/70">
|
||||
<ImageOff class="h-10 w-10" />
|
||||
<p class="text-sm">{i18n.t("products.edit.imageFailed")}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<img
|
||||
src={current}
|
||||
alt={altFor(openIndex ?? 0)}
|
||||
class="z-[1] max-h-full max-w-full object-contain"
|
||||
decoding="async"
|
||||
referrerpolicy="no-referrer"
|
||||
onerror={() => markBroken(current)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if images.length > 1}
|
||||
<button
|
||||
type="button"
|
||||
class="absolute right-2 z-10 rounded-full bg-white/10 p-3 text-white hover:bg-white/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/60 sm:right-6"
|
||||
aria-label={i18n.t("products.edit.nextImage")}
|
||||
data-testid="product-image-next"
|
||||
onclick={() => show((openIndex ?? 0) + 1)}
|
||||
>
|
||||
<ChevronRight class="h-6 w-6" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if images.length > 1}
|
||||
<div class="flex shrink-0 justify-center gap-2 overflow-x-auto px-4 pb-4">
|
||||
{#each images as src, i (src)}
|
||||
<button
|
||||
type="button"
|
||||
class={cn(
|
||||
"h-14 w-14 shrink-0 overflow-hidden rounded border-2 bg-black/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/60",
|
||||
i === openIndex ? "border-white" : "border-transparent opacity-60 hover:opacity-100"
|
||||
)}
|
||||
aria-label={i18n.t("products.edit.openImageAt", { index: i + 1 })}
|
||||
aria-current={i === openIndex ? "true" : undefined}
|
||||
onclick={() => show(i)}
|
||||
>
|
||||
{#if broken.has(src)}
|
||||
<span class="flex h-full w-full items-center justify-center">
|
||||
<ImageOff class="h-4 w-4 text-white/50" />
|
||||
</span>
|
||||
{:else}
|
||||
<img
|
||||
{src}
|
||||
alt={altFor(i)}
|
||||
class="h-full w-full object-contain"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
referrerpolicy="no-referrer"
|
||||
onerror={() => markBroken(src)}
|
||||
/>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -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";
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex min-w-0 flex-1 flex-col p-3 md:min-w-[10rem]">
|
||||
<div class="flex min-w-0 flex-1 items-start gap-3 p-3 md:min-w-[10rem]">
|
||||
<ProductThumb
|
||||
src={productThumbUrl(product)}
|
||||
alt={productDisplayName(product, kind)}
|
||||
class="mt-0.5"
|
||||
/>
|
||||
<div class="flex min-w-0 flex-1 flex-col">
|
||||
<div class="truncate font-mono text-xs text-muted-foreground">{productSku(product)}</div>
|
||||
<div class="min-w-0 font-medium">
|
||||
{#if editingId !== null && String(editingId) === String(product.id)}
|
||||
@@ -639,6 +647,7 @@ import { Check, Edit, MoreHorizontal, X } from "@lucide/svelte";
|
||||
{/if}
|
||||
<ProductStatusBadge status={product.status ?? product.processing_status} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hidden w-[128px] shrink-0 items-center p-3 sm:flex sm:w-[140px]">
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
<script lang="ts">
|
||||
import { ImageOff } from "@lucide/svelte";
|
||||
import { i18n } from "$lib/i18n";
|
||||
import { cn } from "$lib/utils";
|
||||
|
||||
let {
|
||||
src = null,
|
||||
alt = "",
|
||||
size = "md",
|
||||
class: className = ""
|
||||
}: {
|
||||
/** First image URL for the product, or null when the feed carries none. */
|
||||
src?: string | null;
|
||||
alt?: string;
|
||||
size?: "sm" | "md";
|
||||
class?: string;
|
||||
} = $props();
|
||||
|
||||
/** Broken supplier URLs are common — fall back to the placeholder instead of a torn icon. */
|
||||
let failed = $state(false);
|
||||
|
||||
// Reset when the row is recycled onto a different product (virtual list reuse).
|
||||
$effect(() => {
|
||||
void src;
|
||||
failed = false;
|
||||
});
|
||||
|
||||
const box = $derived(size === "sm" ? "h-10 w-10" : "h-14 w-14");
|
||||
const showImage = $derived(Boolean(src) && !failed);
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={cn(
|
||||
"flex shrink-0 items-center justify-center overflow-hidden rounded-md border border-border bg-muted/40",
|
||||
box,
|
||||
className
|
||||
)}
|
||||
data-testid="product-thumb"
|
||||
data-state={showImage ? "image" : "placeholder"}
|
||||
>
|
||||
{#if showImage}
|
||||
<img
|
||||
src={src ?? ""}
|
||||
{alt}
|
||||
class="h-full w-full object-contain"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
referrerpolicy="no-referrer"
|
||||
onerror={() => (failed = true)}
|
||||
/>
|
||||
{:else}
|
||||
<ImageOff
|
||||
class="h-4 w-4 text-muted-foreground/60"
|
||||
aria-label={i18n.t("products.table.noImage")}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string | null>(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 @@
|
||||
<Alert tone="info" message={i18n.t("settings.permissions.ownerOnly")} />
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<p class="text-sm text-muted-foreground" data-testid="permissions-summary">
|
||||
{i18n.t("settings.permissions.summary", {
|
||||
allowed: summary.allowed,
|
||||
total: summary.total
|
||||
})}
|
||||
<fieldset class="space-y-2" disabled={readOnly}>
|
||||
<legend class="text-sm font-medium">{i18n.t("settings.permissions.roleHeading")}</legend>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{i18n.t("settings.permissions.roleHint")}
|
||||
</p>
|
||||
{#if !readOnly}
|
||||
<div class="flex gap-2">
|
||||
<Button variant="outline" size="sm" data-testid="permissions-allow-all" onclick={allowEverything}>
|
||||
{i18n.t("settings.permissions.allowAll")}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" data-testid="permissions-deny-all" onclick={denyEverything}>
|
||||
{i18n.t("settings.permissions.denyAll")}
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Input
|
||||
type="search"
|
||||
bind:value={search}
|
||||
placeholder={i18n.t("settings.permissions.searchPlaceholder")}
|
||||
aria-label={i18n.t("settings.permissions.searchPlaceholder")}
|
||||
/>
|
||||
|
||||
<div class="max-h-[24rem] space-y-5 overflow-y-auto pr-1">
|
||||
{#if visibleSections.length === 0}
|
||||
<p class="py-8 text-center text-sm text-muted-foreground">
|
||||
{i18n.t("settings.permissions.noMatches")}
|
||||
<div class="grid gap-2 sm:grid-cols-2" role="radiogroup" aria-label={i18n.t("settings.permissions.roleHeading")}>
|
||||
{#each PERMISSION_ROLES as role (role.id)}
|
||||
{@const selected = activeRole === role.id}
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
disabled={readOnly}
|
||||
data-testid={`perm-role-${role.id}`}
|
||||
class="flex items-start gap-2 rounded-md border px-3 py-2.5 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-60 {selected
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-border hover:bg-muted/40'}"
|
||||
onclick={() => applyRole(role.id)}
|
||||
>
|
||||
<span
|
||||
class="mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-full border {selected
|
||||
? 'border-primary bg-primary text-primary-foreground'
|
||||
: 'border-muted-foreground/40'}"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{#if selected}<Check class="h-3 w-3" />{/if}
|
||||
</span>
|
||||
<span class="min-w-0">
|
||||
<span class="block text-sm font-medium">{roleLabel(role.id)}</span>
|
||||
<span class="block text-xs text-muted-foreground">{roleDescription(role.id)}</span>
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{#if activeRole === CUSTOM_ROLE_ID}
|
||||
<p class="rounded-md bg-muted/50 px-3 py-2 text-xs text-muted-foreground" data-testid="perm-role-custom">
|
||||
<span class="font-medium text-foreground">{roleLabel(CUSTOM_ROLE_ID)}</span>
|
||||
— {roleDescription(CUSTOM_ROLE_ID)}
|
||||
</p>
|
||||
{/if}
|
||||
{#each visibleSections as section (section.id)}
|
||||
<section class="space-y-1">
|
||||
<h3
|
||||
class="sticky top-0 bg-background py-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground"
|
||||
>
|
||||
{sectionLabel(section.id)}
|
||||
</h3>
|
||||
{#each section.entries as entry (entry.key)}
|
||||
{@const allowed = entry.plan_allowed && !isDenied(denied, entry.key)}
|
||||
{@const locked = !entry.plan_allowed}
|
||||
<div
|
||||
class="flex items-center gap-3 rounded-md px-2 py-1.5 hover:bg-muted/40 {indentClass(
|
||||
entry.key
|
||||
)}"
|
||||
>
|
||||
<Checkbox
|
||||
id={`perm-${entry.key}`}
|
||||
checked={allowed}
|
||||
disabled={readOnly || locked}
|
||||
aria-label={labelFor(entry.key)}
|
||||
data-testid={`perm-${entry.key}`}
|
||||
onchange={() => toggle(entry.key, !allowed)}
|
||||
/>
|
||||
<label
|
||||
for={`perm-${entry.key}`}
|
||||
class="min-w-0 flex-1 cursor-pointer text-sm {locked
|
||||
? 'text-muted-foreground'
|
||||
: 'text-foreground'}"
|
||||
>
|
||||
{labelFor(entry.key)}
|
||||
</label>
|
||||
{#if locked}
|
||||
<Badge variant="secondary">{i18n.t("settings.permissions.planLocked")}</Badge>
|
||||
{/if}
|
||||
</fieldset>
|
||||
|
||||
<div class="border-t border-border pt-3">
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center gap-2 text-left text-sm font-medium hover:text-primary"
|
||||
aria-expanded={advancedOpen}
|
||||
data-testid="permissions-advanced-toggle"
|
||||
onclick={() => (advancedOpen = !advancedOpen)}
|
||||
>
|
||||
{#if advancedOpen}
|
||||
<ChevronDown class="h-4 w-4" />
|
||||
{:else}
|
||||
<ChevronRight class="h-4 w-4" />
|
||||
{/if}
|
||||
<span>{i18n.t("settings.permissions.advanced")}</span>
|
||||
<span class="ml-auto text-xs font-normal text-muted-foreground" data-testid="permissions-summary">
|
||||
{i18n.t("settings.permissions.summary", {
|
||||
allowed: summary.allowed,
|
||||
total: summary.total
|
||||
})}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{#if advancedOpen}
|
||||
<div class="mt-3 space-y-3">
|
||||
{#if !readOnly}
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" data-testid="permissions-allow-all" onclick={allowEverything}>
|
||||
{i18n.t("settings.permissions.allowAll")}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" data-testid="permissions-deny-all" onclick={denyEverything}>
|
||||
{i18n.t("settings.permissions.denyAll")}
|
||||
</Button>
|
||||
</div>
|
||||
{/each}
|
||||
</section>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
<Input
|
||||
type="search"
|
||||
bind:value={search}
|
||||
placeholder={i18n.t("settings.permissions.searchPlaceholder")}
|
||||
aria-label={i18n.t("settings.permissions.searchPlaceholder")}
|
||||
/>
|
||||
|
||||
<div class="max-h-[20rem] space-y-5 overflow-y-auto pr-1">
|
||||
{#if visibleSections.length === 0}
|
||||
<p class="py-8 text-center text-sm text-muted-foreground">
|
||||
{i18n.t("settings.permissions.noMatches")}
|
||||
</p>
|
||||
{/if}
|
||||
{#each visibleSections as section (section.id)}
|
||||
<section class="space-y-1">
|
||||
<h3
|
||||
class="sticky top-0 bg-background py-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground"
|
||||
>
|
||||
{sectionLabel(section.id)}
|
||||
</h3>
|
||||
{#each section.entries as entry (entry.key)}
|
||||
{@const allowed = entry.plan_allowed && !isDenied(denied, entry.key)}
|
||||
{@const locked = !entry.plan_allowed}
|
||||
<div
|
||||
class="flex items-center gap-3 rounded-md px-2 py-1.5 hover:bg-muted/40 {indentClass(
|
||||
entry.key
|
||||
)}"
|
||||
>
|
||||
<Checkbox
|
||||
id={`perm-${entry.key}`}
|
||||
checked={allowed}
|
||||
disabled={readOnly || locked}
|
||||
aria-label={labelFor(entry.key)}
|
||||
data-testid={`perm-${entry.key}`}
|
||||
onchange={() => toggle(entry.key, !allowed)}
|
||||
/>
|
||||
<label
|
||||
for={`perm-${entry.key}`}
|
||||
class="min-w-0 flex-1 cursor-pointer text-sm {locked
|
||||
? 'text-muted-foreground'
|
||||
: 'text-foreground'}"
|
||||
>
|
||||
{labelFor(entry.key)}
|
||||
</label>
|
||||
{#if locked}
|
||||
<Badge variant="secondary">{i18n.t("settings.permissions.planLocked")}</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</section>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -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"
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
};
|
||||
|
||||
@@ -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": "エリアごとに細かく調整"
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
};
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string>, allKeys: string[]): string[] {
|
||||
export function normalizeDenied(denied: Set<string>, allKeys: string[]): string[] {
|
||||
const known = new Set(allKeys);
|
||||
const out: string[] = [];
|
||||
for (const key of denied) {
|
||||
@@ -122,7 +122,7 @@ function normalize(denied: Set<string>, 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<string>();
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
));
|
||||
</script>
|
||||
|
||||
{#if loading}
|
||||
@@ -804,6 +812,7 @@
|
||||
<p class="text-xs text-text-muted">{i18n.t("dashboard.quickLinksHint")}</p>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
{#if canBrowseFeeds}
|
||||
<a
|
||||
href="/feeds"
|
||||
class="group flex items-center gap-3 rounded-xl border border-border bg-surface px-3 py-2.5 text-text shadow-sm transition hover:border-ring/40 hover:bg-accent/30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
@@ -818,6 +827,8 @@
|
||||
</div>
|
||||
<ArrowRight class="h-4 w-4 shrink-0 text-text-muted transition group-hover:translate-x-0.5" />
|
||||
</a>
|
||||
{/if}
|
||||
{#if canBrowseProducts}
|
||||
<a
|
||||
href="/products"
|
||||
class="group flex items-center gap-3 rounded-xl border border-border bg-surface px-3 py-2.5 text-text shadow-sm transition hover:border-ring/40 hover:bg-accent/30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
@@ -833,6 +844,7 @@
|
||||
</div>
|
||||
<ArrowRight class="h-4 w-4 shrink-0 text-text-muted transition group-hover:translate-x-0.5" />
|
||||
</a>
|
||||
{/if}
|
||||
{#if canMonitorJobs}
|
||||
<a
|
||||
href="/processing"
|
||||
|
||||
Reference in New Issue
Block a user