major fixes

This commit is contained in:
2026-08-22 18:51:17 +02:00
parent 0ff24b1534
commit 0c154254c3
36 changed files with 2212 additions and 42 deletions
+4
View File
@@ -35,6 +35,10 @@ export type CreditsLike = {
sections?: Record<string, boolean>;
disabled_features?: string[];
feature_etag?: string;
/** True when the company owner narrowed this member's access (settings > team). */
member_restricted?: boolean;
/** Keys the plan allows but the member overlay denies. */
member_denied_features?: string[];
};
export type UpgradeCta = {
@@ -0,0 +1,41 @@
<script lang="ts">
import { Lock } from "@lucide/svelte";
import { i18n } from "$lib/i18n";
let {
featureKey = undefined,
compact = false
}: {
/** The denied feature key — shown as a hint so an admin knows what to re-enable. */
featureKey?: string;
/** Tighter spacing when embedded under an existing page heading. */
compact?: boolean;
} = $props();
</script>
<div
class={compact ? "space-y-4" : "mx-auto max-w-2xl space-y-4 py-6"}
data-testid="access-restricted-panel"
>
<div class="flex gap-4 rounded-lg border border-border bg-muted/30 px-5 py-5">
<div
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground"
aria-hidden="true"
>
<Lock class="h-5 w-5" />
</div>
<div class="min-w-0 space-y-1.5">
<h2 class="text-base font-semibold text-foreground">
{i18n.t("access.restricted.title")}
</h2>
<p class="text-sm text-muted-foreground">
{i18n.t("access.restricted.message")}
</p>
{#if featureKey}
<p class="pt-1 text-xs text-muted-foreground">
{i18n.t("access.restricted.featureHint", { feature: featureKey })}
</p>
{/if}
</div>
</div>
</div>
@@ -9,6 +9,7 @@
} from "$lib/plan-capabilities";
import { upgradeMessageForFeature } from "$lib/plan-upgrade-message";
import PlanUpgradePanel from "$lib/components/PlanUpgradePanel.svelte";
import AccessRestrictedPanel from "$lib/components/AccessRestrictedPanel.svelte";
import Spinner from "$lib/components/Spinner.svelte";
import { i18n } from "$lib/i18n";
@@ -30,6 +31,8 @@
const allowed = $derived(
!gated || !featureKey || (!awaitingMatrix && planCapabilities.can(featureKey))
);
/** Owner-set member restriction — a plan upgrade would not unlock it. */
const memberDenied = $derived(Boolean(featureKey) && planCapabilities.memberDenied(featureKey!));
const gate = $derived(
featureKey
? upgradeMessageForFeature(featureKey, authSession.isCompanyAdmin, {
@@ -48,6 +51,8 @@
</div>
{:else if allowed}
{@render children()}
{:else if memberDenied}
<AccessRestrictedPanel featureKey={featureKey ?? undefined} />
{:else if gate}
<PlanUpgradePanel
title={gate.title}
+5 -33
View File
@@ -4,6 +4,11 @@ export type {
ProductListURLFilters,
ProductTab
} from "../../products-search";
export {
coerceImageUrl,
coerceImageUrlList,
productImageUrls
} from "../../product-images";
export {
hasExplicitProductTab,
isProductTab,
@@ -847,39 +852,6 @@ function digAttrBag(
return undefined;
}
/** Collect http(s) image URLs from mapped_data (main_image + more_images). */
export function productImageUrls(product: ProductRow | null | undefined): string[] {
const mapped = asRecord(product?.mapped_data);
if (!mapped) return [];
const out: string[] = [];
const seen = new Set<string>();
const push = (raw: unknown) => {
if (typeof raw === "string") {
const url = raw.trim();
if (!/^https?:\/\//i.test(url) || seen.has(url)) return;
seen.add(url);
out.push(url);
return;
}
if (Array.isArray(raw)) {
for (const item of raw) push(item);
return;
}
const rec = asRecord(raw);
if (!rec) return;
for (const key of ["url", "src", "href", "link"]) {
if (rec[key] != null) push(rec[key]);
}
};
for (const key of ["main_image", "mainImage", "image", "image_url", "imageUrl"]) {
if (mapped[key] != null) push(mapped[key]);
}
for (const key of ["more_images", "moreImages", "images", "additional_images"]) {
if (mapped[key] != null) push(mapped[key]);
}
return out;
}
/** True when product carries an EPREL id or enriched energy-label payload. */
export function productHasEprel(product: ProductRow | null | undefined): boolean {
if (!product) return false;
@@ -0,0 +1,239 @@
<script lang="ts">
import { api } from "$lib/api";
import { i18n } from "$lib/i18n";
import { notifyApiError } from "$lib/notify";
import { planCapabilities } from "$lib/plan-capabilities.svelte";
import { sectionLabel } from "$lib/plan-feature-catalog";
import { Badge, Button, Checkbox, Dialog, Input, Spinner } from "$lib/components/ui";
import Alert from "$lib/components/Alert.svelte";
import {
catalogKeys,
denyAll,
filterCatalog,
isDenied,
permissionSummary,
samePermissions,
setPermissionAllowed,
type MemberPermissionsView,
type PermissionCatalog
} from "$lib/member-permissions";
let {
open = $bindable(false),
member,
canEdit = false,
onSaved = undefined
}: {
open?: boolean;
/** The teammate being edited — null while the dialog is closed. */
member: { user_id: string; email: string; is_owner?: boolean } | null;
/** Only the company owner may change permissions; others get a read-only view. */
canEdit?: boolean;
onSaved?: (view: MemberPermissionsView) => void;
} = $props();
let catalog = $state<PermissionCatalog | null>(null);
let denied = $state<string[]>([]);
let savedDenied = $state<string[]>([]);
let loading = $state(false);
let saving = $state(false);
let loadError = $state<string | null>(null);
let search = $state("");
let isOwnerMember = $state(false);
const allKeys = $derived(catalogKeys(catalog));
const summary = $derived(permissionSummary(catalog, denied));
const dirty = $derived(!samePermissions(denied, savedDenied));
const readOnly = $derived(!canEdit || isOwnerMember);
function labelFor(key: string): string {
const translated = i18n.t(`plan.feature.${key}`);
// Untranslated keys fall back to the raw key rather than an empty cell.
return translated === `plan.feature.${key}` ? key : translated;
}
const visibleSections = $derived(filterCatalog(catalog, search, labelFor));
/** Reload whenever the dialog opens for a member (never on every keystroke). */
$effect(() => {
if (!open || !member) return;
const userId = member.user_id;
const ac = new AbortController();
loading = true;
loadError = null;
search = "";
void (async () => {
try {
const [catalogPayload, view] = await Promise.all([
api<PermissionCatalog>("/api/team/permission-catalog", { signal: ac.signal }),
api<MemberPermissionsView>(`/api/team/${userId}/permissions`, { signal: ac.signal })
]);
catalog = catalogPayload;
denied = [...(view.denied ?? [])];
savedDenied = [...(view.denied ?? [])];
isOwnerMember = Boolean(view.is_owner);
} catch (err) {
if ((err as { name?: string })?.name === "AbortError") return;
loadError = i18n.t("settings.permissions.loadFailed");
} finally {
loading = false;
}
})();
return () => ac.abort();
});
function toggle(key: string, nextAllowed: boolean) {
if (readOnly) return;
denied = setPermissionAllowed(denied, key, nextAllowed, allKeys);
}
function allowEverything() {
if (readOnly) return;
denied = [];
}
function denyEverything() {
if (readOnly) return;
denied = denyAll(catalog);
}
async function save() {
if (!member || readOnly || saving) return;
saving = true;
try {
const view = await api<MemberPermissionsView>(`/api/team/${member.user_id}/permissions`, {
method: "PUT",
body: { denied }
});
savedDenied = [...(view.denied ?? [])];
denied = [...(view.denied ?? [])];
// The editor may have just restricted themselves out of a section elsewhere in
// this tab — refetch so the sidebar and route guard agree with the server.
await planCapabilities.refresh(undefined, true);
onSaved?.(view);
open = false;
} catch (err) {
notifyApiError(err, i18n.t("settings.permissions.saveFailed"));
} finally {
saving = false;
}
}
/** Indent nested keys so "Products - Error tab" reads as a child of "Products". */
function indentClass(key: string): string {
const depth = key.split(".").length - 2;
if (depth <= 0) return "";
return depth === 1 ? "pl-6" : "pl-12";
}
</script>
<Dialog
bind:open
title={i18n.t("settings.permissions.title", { email: member?.email ?? "" })}
description={i18n.t("settings.permissions.description")}
class="sm:max-w-2xl sm:min-w-0"
>
<div class="space-y-4 py-2">
{#if loading}
<div class="flex justify-center py-10">
<Spinner />
</div>
{:else if loadError}
<Alert tone="error" message={loadError} />
{:else}
{#if isOwnerMember}
<Alert tone="info" message={i18n.t("settings.permissions.ownerNotice")} />
{:else if !canEdit}
<Alert tone="info" message={i18n.t("settings.permissions.ownerOnly")} />
{/if}
<div class="flex flex-wrap items-center justify-between gap-3">
<p class="text-sm text-muted-foreground" data-testid="permissions-summary">
{i18n.t("settings.permissions.summary", {
allowed: summary.allowed,
total: summary.total
})}
</p>
{#if !readOnly}
<div class="flex gap-2">
<Button variant="outline" size="sm" data-testid="permissions-allow-all" onclick={allowEverything}>
{i18n.t("settings.permissions.allowAll")}
</Button>
<Button variant="outline" size="sm" data-testid="permissions-deny-all" onclick={denyEverything}>
{i18n.t("settings.permissions.denyAll")}
</Button>
</div>
{/if}
</div>
<Input
type="search"
bind:value={search}
placeholder={i18n.t("settings.permissions.searchPlaceholder")}
aria-label={i18n.t("settings.permissions.searchPlaceholder")}
/>
<div class="max-h-[24rem] space-y-5 overflow-y-auto pr-1">
{#if visibleSections.length === 0}
<p class="py-8 text-center text-sm text-muted-foreground">
{i18n.t("settings.permissions.noMatches")}
</p>
{/if}
{#each visibleSections as section (section.id)}
<section class="space-y-1">
<h3
class="sticky top-0 bg-background py-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground"
>
{sectionLabel(section.id)}
</h3>
{#each section.entries as entry (entry.key)}
{@const allowed = entry.plan_allowed && !isDenied(denied, entry.key)}
{@const locked = !entry.plan_allowed}
<div
class="flex items-center gap-3 rounded-md px-2 py-1.5 hover:bg-muted/40 {indentClass(
entry.key
)}"
>
<Checkbox
id={`perm-${entry.key}`}
checked={allowed}
disabled={readOnly || locked}
aria-label={labelFor(entry.key)}
data-testid={`perm-${entry.key}`}
onchange={() => toggle(entry.key, !allowed)}
/>
<label
for={`perm-${entry.key}`}
class="min-w-0 flex-1 cursor-pointer text-sm {locked
? 'text-muted-foreground'
: 'text-foreground'}"
>
{labelFor(entry.key)}
</label>
{#if locked}
<Badge variant="secondary">{i18n.t("settings.permissions.planLocked")}</Badge>
{/if}
</div>
{/each}
</section>
{/each}
</div>
{/if}
</div>
{#snippet footer()}
<Button variant="outline" onclick={() => (open = false)}>
{readOnly ? i18n.t("common.close") : i18n.t("common.cancel")}
</Button>
{#if !readOnly}
<Button
data-testid="permissions-save"
disabled={saving || loading || !dirty}
loading={saving}
onclick={save}
>
{i18n.t("settings.permissions.save")}
</Button>
{/if}
{/snippet}
</Dialog>
+21
View File
@@ -5658,4 +5658,25 @@ export const de: MessageDict = {
"files.empty": "Noch keine Uploads.",
"products.filters.sortProducts": "Produkte sortieren",
"products.table.options": "Optionen",
"access.restricted.title": "Zugriff eingeschränkt",
"access.restricted.message": "Der Inhaber Ihres Unternehmens hat den Zugriff auf diesen Bereich deaktiviert. Bitten Sie ihn, ihn bei Bedarf freizuschalten.",
"access.restricted.featureHint": "Berechtigung: {feature}",
"settings.permissions.action": "Berechtigungen",
"settings.permissions.title": "Berechtigungen für {email}",
"settings.permissions.description": "Legen Sie fest, was dieses Teammitglied öffnen darf. Ein deaktivierter Bereich wird in der Seitenleiste ausgeblendet und in der API gesperrt.",
"settings.permissions.ownerNotice": "Der Unternehmensinhaber hat immer vollen Zugriff und kann nicht eingeschränkt werden.",
"settings.permissions.ownerOnly": "Nur der Unternehmensinhaber kann Berechtigungen ändern.",
"settings.permissions.summary": "{allowed} von {total} Bereichen erlaubt",
"settings.permissions.allowAll": "Alle erlauben",
"settings.permissions.denyAll": "Alle sperren",
"settings.permissions.planLocked": "Nicht in Ihrem Tarif",
"settings.permissions.searchPlaceholder": "Bereiche suchen…",
"settings.permissions.noMatches": "Keine Bereiche passen zu dieser Suche.",
"settings.permissions.save": "Berechtigungen speichern",
"settings.permissions.saved": "Berechtigungen für {email} aktualisiert",
"settings.permissions.saveFailed": "Berechtigungen konnten nicht aktualisiert werden",
"settings.permissions.loadFailed": "Berechtigungen konnten nicht geladen werden",
"settings.permissions.restrictedBadge": "Eingeschränkt",
"settings.permissions.restrictedHint": "{count} Bereiche deaktiviert",
"settings.tabLockedByAdmin": "Vom Unternehmensinhaber deaktiviert"
};
+22 -1
View File
@@ -5743,5 +5743,26 @@ export const en: MessageDict = {
"files.loading": "Loading uploads…",
"files.empty": "No uploads yet.",
"products.filters.sortProducts": "Sort Products",
"products.table.options": "Options"
"products.table.options": "Options",
"access.restricted.title": "Access restricted",
"access.restricted.message": "Your company owner has turned off access to this area. Ask them to enable it if you need it.",
"access.restricted.featureHint": "Permission: {feature}",
"settings.permissions.action": "Permissions",
"settings.permissions.title": "Permissions for {email}",
"settings.permissions.description": "Choose what this teammate can open. Turning an area off hides it from their sidebar and blocks it in the API.",
"settings.permissions.ownerNotice": "The company owner always has full access and cannot be restricted.",
"settings.permissions.ownerOnly": "Only the company owner can change permissions.",
"settings.permissions.summary": "{allowed} of {total} areas allowed",
"settings.permissions.allowAll": "Allow all",
"settings.permissions.denyAll": "Deny all",
"settings.permissions.planLocked": "Not in your plan",
"settings.permissions.searchPlaceholder": "Search areas…",
"settings.permissions.noMatches": "No areas match that search.",
"settings.permissions.save": "Save permissions",
"settings.permissions.saved": "Permissions updated for {email}",
"settings.permissions.saveFailed": "Could not update permissions",
"settings.permissions.loadFailed": "Could not load permissions",
"settings.permissions.restrictedBadge": "Restricted",
"settings.permissions.restrictedHint": "{count} areas turned off",
"settings.tabLockedByAdmin": "Turned off by your company owner"
};
+21
View File
@@ -5667,4 +5667,25 @@ export const es: MessageDict = {
"files.empty": "Aún no hay cargas.",
"products.filters.sortProducts": "Ordenar productos",
"products.table.options": "Opciones",
"access.restricted.title": "Acceso restringido",
"access.restricted.message": "El propietario de tu empresa ha desactivado el acceso a esta sección. Pídele que la active si la necesitas.",
"access.restricted.featureHint": "Permiso: {feature}",
"settings.permissions.action": "Permisos",
"settings.permissions.title": "Permisos de {email}",
"settings.permissions.description": "Elige qué puede abrir este miembro. Al desactivar una sección se oculta de su menú lateral y se bloquea en la API.",
"settings.permissions.ownerNotice": "El propietario de la empresa siempre tiene acceso completo y no se puede restringir.",
"settings.permissions.ownerOnly": "Solo el propietario de la empresa puede cambiar los permisos.",
"settings.permissions.summary": "{allowed} de {total} secciones permitidas",
"settings.permissions.allowAll": "Permitir todo",
"settings.permissions.denyAll": "Denegar todo",
"settings.permissions.planLocked": "No incluido en tu plan",
"settings.permissions.searchPlaceholder": "Buscar secciones…",
"settings.permissions.noMatches": "Ninguna sección coincide con la búsqueda.",
"settings.permissions.save": "Guardar permisos",
"settings.permissions.saved": "Permisos actualizados para {email}",
"settings.permissions.saveFailed": "No se pudieron actualizar los permisos",
"settings.permissions.loadFailed": "No se pudieron cargar los permisos",
"settings.permissions.restrictedBadge": "Restringido",
"settings.permissions.restrictedHint": "{count} secciones desactivadas",
"settings.tabLockedByAdmin": "Desactivado por el propietario de tu empresa"
};
+21
View File
@@ -5667,4 +5667,25 @@ export const fr: MessageDict = {
"files.empty": "Aucun téléversement pour l’instant.",
"products.filters.sortProducts": "Trier les produits",
"products.table.options": "Options",
"access.restricted.title": "Accès restreint",
"access.restricted.message": "Le propriétaire de votre entreprise a désactivé laccès à cette section. Demandez-lui de lactiver si vous en avez besoin.",
"access.restricted.featureHint": "Autorisation : {feature}",
"settings.permissions.action": "Autorisations",
"settings.permissions.title": "Autorisations de {email}",
"settings.permissions.description": "Choisissez ce que ce membre peut ouvrir. Désactiver une section la masque dans sa barre latérale et la bloque dans lAPI.",
"settings.permissions.ownerNotice": "Le propriétaire de lentreprise a toujours un accès complet et ne peut pas être restreint.",
"settings.permissions.ownerOnly": "Seul le propriétaire de lentreprise peut modifier les autorisations.",
"settings.permissions.summary": "{allowed} sur {total} sections autorisées",
"settings.permissions.allowAll": "Tout autoriser",
"settings.permissions.denyAll": "Tout refuser",
"settings.permissions.planLocked": "Absent de votre forfait",
"settings.permissions.searchPlaceholder": "Rechercher des sections…",
"settings.permissions.noMatches": "Aucune section ne correspond à cette recherche.",
"settings.permissions.save": "Enregistrer les autorisations",
"settings.permissions.saved": "Autorisations mises à jour pour {email}",
"settings.permissions.saveFailed": "Impossible de mettre à jour les autorisations",
"settings.permissions.loadFailed": "Impossible de charger les autorisations",
"settings.permissions.restrictedBadge": "Restreint",
"settings.permissions.restrictedHint": "{count} sections désactivées",
"settings.tabLockedByAdmin": "Désactivé par le propriétaire de votre entreprise"
};
+21
View File
@@ -5667,4 +5667,25 @@ export const it: MessageDict = {
"files.empty": "Nessun caricamento ancora.",
"products.filters.sortProducts": "Ordina prodotti",
"products.table.options": "Opzioni",
"access.restricted.title": "Accesso limitato",
"access.restricted.message": "Il proprietario della tua azienda ha disattivato laccesso a questa sezione. Chiedigli di abilitarla se ti serve.",
"access.restricted.featureHint": "Autorizzazione: {feature}",
"settings.permissions.action": "Autorizzazioni",
"settings.permissions.title": "Autorizzazioni per {email}",
"settings.permissions.description": "Scegli cosa può aprire questo membro del team. Disattivare una sezione la nasconde dalla barra laterale e la blocca nellAPI.",
"settings.permissions.ownerNotice": "Il proprietario dellazienda ha sempre accesso completo e non può essere limitato.",
"settings.permissions.ownerOnly": "Solo il proprietario dellazienda può modificare le autorizzazioni.",
"settings.permissions.summary": "{allowed} di {total} sezioni consentite",
"settings.permissions.allowAll": "Consenti tutto",
"settings.permissions.denyAll": "Nega tutto",
"settings.permissions.planLocked": "Non incluso nel tuo piano",
"settings.permissions.searchPlaceholder": "Cerca sezioni…",
"settings.permissions.noMatches": "Nessuna sezione corrisponde alla ricerca.",
"settings.permissions.save": "Salva autorizzazioni",
"settings.permissions.saved": "Autorizzazioni aggiornate per {email}",
"settings.permissions.saveFailed": "Impossibile aggiornare le autorizzazioni",
"settings.permissions.loadFailed": "Impossibile caricare le autorizzazioni",
"settings.permissions.restrictedBadge": "Limitato",
"settings.permissions.restrictedHint": "{count} sezioni disattivate",
"settings.tabLockedByAdmin": "Disattivato dal proprietario della tua azienda"
};
+21
View File
@@ -5667,4 +5667,25 @@ export const ja: MessageDict = {
"files.empty": "まだアップロードはありません。",
"products.filters.sortProducts": "商品を並べ替え",
"products.table.options": "オプション",
"access.restricted.title": "アクセスが制限されています",
"access.restricted.message": "会社のオーナーがこのエリアへのアクセスを無効にしています。必要な場合は有効化を依頼してください。",
"access.restricted.featureHint": "権限: {feature}",
"settings.permissions.action": "権限",
"settings.permissions.title": "{email} の権限",
"settings.permissions.description": "このメンバーが開ける範囲を選びます。オフにしたエリアはサイドバーから非表示になり、API でもブロックされます。",
"settings.permissions.ownerNotice": "会社のオーナーは常にフルアクセスを持ち、制限できません。",
"settings.permissions.ownerOnly": "権限を変更できるのは会社のオーナーだけです。",
"settings.permissions.summary": "{total} 件中 {allowed} 件のエリアを許可",
"settings.permissions.allowAll": "すべて許可",
"settings.permissions.denyAll": "すべて拒否",
"settings.permissions.planLocked": "現在のプランに含まれません",
"settings.permissions.searchPlaceholder": "エリアを検索…",
"settings.permissions.noMatches": "検索に一致するエリアはありません。",
"settings.permissions.save": "権限を保存",
"settings.permissions.saved": "{email} の権限を更新しました",
"settings.permissions.saveFailed": "権限を更新できませんでした",
"settings.permissions.loadFailed": "権限を読み込めませんでした",
"settings.permissions.restrictedBadge": "制限あり",
"settings.permissions.restrictedHint": "{count} 件のエリアを無効化",
"settings.tabLockedByAdmin": "会社のオーナーが無効にしています"
};
+21
View File
@@ -5667,4 +5667,25 @@ export const nl: MessageDict = {
"files.empty": "Nog geen uploads.",
"products.filters.sortProducts": "Producten sorteren",
"products.table.options": "Opties",
"access.restricted.title": "Toegang beperkt",
"access.restricted.message": "De eigenaar van je bedrijf heeft toegang tot dit onderdeel uitgeschakeld. Vraag of het weer aangezet kan worden als je het nodig hebt.",
"access.restricted.featureHint": "Rechten: {feature}",
"settings.permissions.action": "Rechten",
"settings.permissions.title": "Rechten voor {email}",
"settings.permissions.description": "Bepaal wat dit teamlid mag openen. Een uitgeschakeld onderdeel verdwijnt uit de zijbalk en wordt geblokkeerd in de API.",
"settings.permissions.ownerNotice": "De bedrijfseigenaar heeft altijd volledige toegang en kan niet worden beperkt.",
"settings.permissions.ownerOnly": "Alleen de bedrijfseigenaar kan rechten wijzigen.",
"settings.permissions.summary": "{allowed} van {total} onderdelen toegestaan",
"settings.permissions.allowAll": "Alles toestaan",
"settings.permissions.denyAll": "Alles blokkeren",
"settings.permissions.planLocked": "Niet in je abonnement",
"settings.permissions.searchPlaceholder": "Onderdelen zoeken…",
"settings.permissions.noMatches": "Geen onderdelen gevonden voor deze zoekopdracht.",
"settings.permissions.save": "Rechten opslaan",
"settings.permissions.saved": "Rechten bijgewerkt voor {email}",
"settings.permissions.saveFailed": "Rechten konden niet worden bijgewerkt",
"settings.permissions.loadFailed": "Rechten konden niet worden geladen",
"settings.permissions.restrictedBadge": "Beperkt",
"settings.permissions.restrictedHint": "{count} onderdelen uitgeschakeld",
"settings.tabLockedByAdmin": "Uitgeschakeld door de bedrijfseigenaar"
};
+21
View File
@@ -5667,4 +5667,25 @@ export const pl: MessageDict = {
"files.empty": "Brak przesłań.",
"products.filters.sortProducts": "Sortuj produkty",
"products.table.options": "Opcje",
"access.restricted.title": "Dostęp ograniczony",
"access.restricted.message": "Właściciel firmy wyłączył dostęp do tej sekcji. Poproś go o włączenie, jeśli jej potrzebujesz.",
"access.restricted.featureHint": "Uprawnienie: {feature}",
"settings.permissions.action": "Uprawnienia",
"settings.permissions.title": "Uprawnienia dla {email}",
"settings.permissions.description": "Wybierz, co może otwierać ten członek zespołu. Wyłączona sekcja znika z jego menu bocznego i jest blokowana w API.",
"settings.permissions.ownerNotice": "Właściciel firmy zawsze ma pełny dostęp i nie można go ograniczać.",
"settings.permissions.ownerOnly": "Tylko właściciel firmy może zmieniać uprawnienia.",
"settings.permissions.summary": "Dozwolone {allowed} z {total} sekcji",
"settings.permissions.allowAll": "Zezwól na wszystko",
"settings.permissions.denyAll": "Zablokuj wszystko",
"settings.permissions.planLocked": "Niedostępne w Twoim planie",
"settings.permissions.searchPlaceholder": "Szukaj sekcji…",
"settings.permissions.noMatches": "Brak sekcji pasujących do wyszukiwania.",
"settings.permissions.save": "Zapisz uprawnienia",
"settings.permissions.saved": "Zaktualizowano uprawnienia dla {email}",
"settings.permissions.saveFailed": "Nie udało się zaktualizować uprawnień",
"settings.permissions.loadFailed": "Nie udało się wczytać uprawnień",
"settings.permissions.restrictedBadge": "Ograniczony",
"settings.permissions.restrictedHint": "Wyłączonych sekcji: {count}",
"settings.tabLockedByAdmin": "Wyłączone przez właściciela firmy"
};
+21
View File
@@ -5667,4 +5667,25 @@ export const pt: MessageDict = {
"files.empty": "Ainda sem carregamentos.",
"products.filters.sortProducts": "Ordenar produtos",
"products.table.options": "Opções",
"access.restricted.title": "Acesso restrito",
"access.restricted.message": "O proprietário da sua empresa desativou o acesso a esta área. Peça-lhe para a ativar se precisar dela.",
"access.restricted.featureHint": "Permissão: {feature}",
"settings.permissions.action": "Permissões",
"settings.permissions.title": "Permissões de {email}",
"settings.permissions.description": "Escolha o que este membro pode abrir. Desativar uma área oculta-a da barra lateral e bloqueia-a na API.",
"settings.permissions.ownerNotice": "O proprietário da empresa tem sempre acesso total e não pode ser restringido.",
"settings.permissions.ownerOnly": "Só o proprietário da empresa pode alterar permissões.",
"settings.permissions.summary": "{allowed} de {total} áreas permitidas",
"settings.permissions.allowAll": "Permitir tudo",
"settings.permissions.denyAll": "Negar tudo",
"settings.permissions.planLocked": "Não incluído no seu plano",
"settings.permissions.searchPlaceholder": "Procurar áreas…",
"settings.permissions.noMatches": "Nenhuma área corresponde a essa procura.",
"settings.permissions.save": "Guardar permissões",
"settings.permissions.saved": "Permissões atualizadas para {email}",
"settings.permissions.saveFailed": "Não foi possível atualizar as permissões",
"settings.permissions.loadFailed": "Não foi possível carregar as permissões",
"settings.permissions.restrictedBadge": "Restrito",
"settings.permissions.restrictedHint": "{count} áreas desativadas",
"settings.tabLockedByAdmin": "Desativado pelo proprietário da sua empresa"
};
+163
View File
@@ -0,0 +1,163 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
catalogKeys,
denyAll,
featureAncestors,
filterCatalog,
isDenied,
permissionSummary,
samePermissions,
setPermissionAllowed,
type PermissionCatalog
} from "./member-permissions.ts";
const catalog: PermissionCatalog = {
sections: [
{
id: "catalog",
entries: [
{ key: "catalog.products", section: "catalog", parent: "", plan_allowed: true },
{
key: "catalog.products.tab_error",
section: "catalog",
parent: "catalog.products",
plan_allowed: true
},
{
key: "catalog.products.export_selection",
section: "catalog",
parent: "catalog.products",
plan_allowed: true
},
{ key: "catalog.categories", section: "catalog", parent: "", plan_allowed: true }
]
},
{
id: "stores",
entries: [
{ key: "stores.hub", section: "stores", parent: "", plan_allowed: false },
{ key: "stores.shopify", section: "stores", parent: "", plan_allowed: false }
]
}
]
};
const ALL = catalogKeys(catalog);
describe("featureAncestors", () => {
it("lists progressively shorter prefixes", () => {
assert.deepEqual(featureAncestors("catalog.products.tab_error"), [
"catalog",
"catalog.products"
]);
assert.deepEqual(featureAncestors("stores"), []);
});
});
describe("isDenied", () => {
it("honours parent-prefix denial", () => {
const denied = ["catalog.products"];
assert.equal(isDenied(denied, "catalog.products"), true);
assert.equal(isDenied(denied, "catalog.products.tab_error"), true);
assert.equal(isDenied(denied, "catalog.categories"), false);
assert.equal(isDenied([], "catalog.products"), false);
});
});
describe("setPermissionAllowed", () => {
it("denying a parent drops redundant child denials", () => {
const got = setPermissionAllowed(
["catalog.products.tab_error"],
"catalog.products",
false,
ALL
);
assert.deepEqual(got, ["catalog.products"]);
});
it("allowing a key lifts the denied ancestor and re-denies its other branches", () => {
const got = setPermissionAllowed(
["catalog.products"],
"catalog.products.tab_error",
true,
ALL
);
// tab_error opens; export_selection must NOT silently open with it.
assert.deepEqual(got, ["catalog.products.export_selection"]);
assert.equal(isDenied(got, "catalog.products.tab_error"), false);
assert.equal(isDenied(got, "catalog.products.export_selection"), true);
assert.equal(isDenied(got, "catalog.products"), false);
});
it("allowing an already-allowed key is a no-op", () => {
assert.deepEqual(setPermissionAllowed(["stores.hub"], "catalog.products", true, ALL), [
"stores.hub"
]);
});
it("denying then allowing the same key round-trips", () => {
const denied = setPermissionAllowed([], "catalog.categories", false, ALL);
assert.deepEqual(denied, ["catalog.categories"]);
assert.deepEqual(setPermissionAllowed(denied, "catalog.categories", true, ALL), []);
});
it("drops keys that are not in the catalog", () => {
const got = setPermissionAllowed(["gone.key"], "catalog.categories", false, ALL);
assert.deepEqual(got, ["catalog.categories"]);
});
it("returns a sorted list so equality checks are stable", () => {
const got = setPermissionAllowed(["stores.hub"], "catalog.categories", false, ALL);
assert.deepEqual(got, ["catalog.categories", "stores.hub"]);
});
});
describe("denyAll", () => {
it("collapses to the top-level keys only", () => {
assert.deepEqual(denyAll(catalog), [
"catalog.categories",
"catalog.products",
"stores.hub",
"stores.shopify"
]);
});
});
describe("permissionSummary", () => {
it("counts only keys the plan already allows", () => {
// stores.* are plan_allowed: false, so they are outside the total.
assert.deepEqual(permissionSummary(catalog, []), { allowed: 4, total: 4 });
assert.deepEqual(permissionSummary(catalog, ["catalog.products"]), { allowed: 1, total: 4 });
assert.deepEqual(permissionSummary(null, []), { allowed: 0, total: 0 });
});
});
describe("samePermissions", () => {
it("ignores order", () => {
assert.equal(samePermissions(["a", "b"], ["b", "a"]), true);
assert.equal(samePermissions(["a"], ["a", "b"]), false);
assert.equal(samePermissions([], []), true);
});
});
describe("filterCatalog", () => {
const labelFor = (key: string) => (key === "catalog.categories" ? "Categories" : key);
it("returns everything for an empty query", () => {
assert.equal(filterCatalog(catalog, " ", labelFor).length, 2);
});
it("matches on key and on label, dropping empty sections", () => {
const byKey = filterCatalog(catalog, "shopify", labelFor);
assert.deepEqual(
byKey.map((s) => s.id),
["stores"]
);
const byLabel = filterCatalog(catalog, "categor", labelFor);
assert.deepEqual(
byLabel.flatMap((s) => s.entries.map((e) => e.key)),
["catalog.categories"]
);
});
});
+172
View File
@@ -0,0 +1,172 @@
/**
* Per-member access permissions (settings > team).
*
* The company owner stores a sparse list of DENIED feature keys per member; everything
* else is inherited (allowed, still bounded by the plan). Denying a parent key denies
* its descendants, mirroring billing.MemberDeniesFeature in
* apps/api/internal/billing/member_permissions.go.
*
* Kept free of $lib/i18n and $app imports so node:test can exercise it directly
* labels come from $lib/plan-feature-catalog at render time.
*/
/** One grantable feature key as returned by GET /api/team/permission-catalog. */
export type PermissionCatalogEntry = {
key: string;
section: string;
/** Nearest grantable ancestor key, or "" for a top-level area. */
parent?: string;
/** False when the company's own plan already denies the key (nothing to grant). */
plan_allowed: boolean;
};
export type PermissionCatalogSection = {
id: string;
entries: PermissionCatalogEntry[];
};
export type PermissionCatalog = {
sections: PermissionCatalogSection[];
};
/** GET/PUT /api/team/{userID}/permissions. */
export type MemberPermissionsView = {
user_id: string;
role: string;
is_owner: boolean;
denied: string[];
restricted: boolean;
};
/** Ancestor keys of "a.b.c" → ["a", "a.b"]. */
export function featureAncestors(key: string): string[] {
const parts = key.split(".");
const out: string[] = [];
for (let i = 1; i < parts.length; i++) out.push(parts.slice(0, i).join("."));
return out;
}
/** True when key, or any ancestor of it, is in the denied set. */
export function isDenied(denied: Iterable<string>, key: string): boolean {
const set = denied instanceof Set ? denied : new Set(denied);
if (set.size === 0) return false;
if (set.has(key)) return true;
return featureAncestors(key).some((parent) => set.has(parent));
}
/** Every catalog key, flattened in section order. */
export function catalogKeys(catalog: PermissionCatalog | null | undefined): string[] {
if (!catalog?.sections) return [];
return catalog.sections.flatMap((section) => section.entries.map((entry) => entry.key));
}
function descendantsOf(key: string, allKeys: string[]): string[] {
return allKeys.filter((candidate) => candidate.startsWith(`${key}.`));
}
/** Direct children of key within allKeys (no grandchildren). */
function childrenOf(key: string, allKeys: string[]): string[] {
const depth = key.split(".").length;
return allKeys.filter(
(candidate) => candidate.startsWith(`${key}.`) && candidate.split(".").length === depth + 1
);
}
/**
* Toggle one key and return the normalized denied list.
*
* Normalization keeps the stored overlay minimal and unambiguous:
* - denying a key drops its now-redundant descendants,
* - allowing a key whose ancestor is denied lifts that ancestor and pushes the denial
* down onto the ancestor's other branches, so no other area silently re-opens.
*/
export function setPermissionAllowed(
denied: Iterable<string>,
key: string,
allowed: boolean,
allKeys: string[]
): string[] {
const next = new Set(denied);
if (!allowed) {
for (const descendant of descendantsOf(key, allKeys)) next.delete(descendant);
next.add(key);
return normalize(next, allKeys);
}
next.delete(key);
// Lift every denied ancestor, re-denying its other branches so only `key` opens up.
for (const ancestor of featureAncestors(key)) {
if (!next.has(ancestor)) continue;
next.delete(ancestor);
const openPath = new Set([key, ...featureAncestors(key)]);
for (const sibling of childrenOf(ancestor, allKeys)) {
if (!openPath.has(sibling)) next.add(sibling);
}
}
return normalize(next, allKeys);
}
/** Drop keys outside the catalog and denials already implied by a denied ancestor. */
function normalize(denied: Set<string>, allKeys: string[]): string[] {
const known = new Set(allKeys);
const out: string[] = [];
for (const key of denied) {
if (!known.has(key)) continue;
if (featureAncestors(key).some((parent) => denied.has(parent))) continue;
out.push(key);
}
return out.sort();
}
/** Deny every grantable key (the always-on shell keys are not in the catalog). */
export function denyAll(catalog: PermissionCatalog | null | undefined): string[] {
const all = catalogKeys(catalog);
return normalize(new Set(all), all);
}
/** Allowed / total counts for the summary line, ignoring keys the plan already denies. */
export function permissionSummary(
catalog: PermissionCatalog | null | undefined,
denied: Iterable<string>
): { allowed: number; total: number } {
const set = new Set(denied);
let allowed = 0;
let total = 0;
for (const section of catalog?.sections ?? []) {
for (const entry of section.entries) {
if (!entry.plan_allowed) continue;
total += 1;
if (!isDenied(set, entry.key)) allowed += 1;
}
}
return { allowed, total };
}
/** True when two denied lists describe the same overlay (order-insensitive). */
export function samePermissions(a: string[], b: string[]): boolean {
if (a.length !== b.length) return false;
const set = new Set(a);
return b.every((key) => set.has(key));
}
/** Filter a catalog to entries matching a free-text query over key and label. */
export function filterCatalog(
catalog: PermissionCatalog | null | undefined,
query: string,
labelFor: (key: string) => string
): PermissionCatalogSection[] {
const needle = query.trim().toLowerCase();
const sections = catalog?.sections ?? [];
if (!needle) return sections;
const out: PermissionCatalogSection[] = [];
for (const section of sections) {
const entries = section.entries.filter(
(entry) =>
entry.key.toLowerCase().includes(needle) ||
labelFor(entry.key).toLowerCase().includes(needle)
);
if (entries.length > 0) out.push({ ...section, entries });
}
return out;
}
@@ -14,6 +14,8 @@ let sectionsState = $state<Record<string, boolean> | null>(null);
let statusState = $state<CapabilitiesStatus>("idle");
let etagState = $state<string | null>(null);
let planNameState = $state<string | null>(null);
/** Feature keys the plan allows but the company owner denied for this member. */
let memberDeniedState = $state<Set<string>>(new Set());
let fetchedOnce = false;
/** Soft TTL so admin gate/plan edits show up without a full reload. */
let fetchedAtMs = 0;
@@ -31,6 +33,13 @@ function applyCredits(credits?: CreditsLike | null) {
if (sections && typeof sections === "object" && !Array.isArray(sections)) {
sectionsState = sections;
}
applyMemberDenied(credits?.member_denied_features);
}
/** Track owner-set per-member denials so gates can say "ask your admin", not "upgrade". */
function applyMemberDenied(denied?: string[] | null) {
if (!Array.isArray(denied)) return;
memberDeniedState = new Set(denied);
}
function applyCapabilities(payload: CapabilitiesResponse) {
@@ -49,6 +58,7 @@ function applyCapabilities(payload: CapabilitiesResponse) {
if (typeof payload.plan_name === "string" && payload.plan_name.trim()) {
planNameState = payload.plan_name.trim();
}
applyMemberDenied(payload.member_denied_features);
}
/** Shared plan capabilities snapshot — fetch once per dashboard session. */
@@ -68,6 +78,23 @@ export const planCapabilities = {
get planName(): string | null {
return planNameState;
},
/** True when the company owner has narrowed this member's access at all. */
get memberRestricted(): boolean {
return memberDeniedState.size > 0;
},
/**
* True when THIS key is off because the company owner turned it off, rather than
* because the plan lacks it. Honours parent-prefix denial, matching the API.
*/
memberDenied(key: string): boolean {
if (memberDeniedState.size === 0) return false;
if (memberDeniedState.has(key)) return true;
const parts = key.split(".");
for (let i = 1; i < parts.length; i++) {
if (memberDeniedState.has(parts.slice(0, i).join("."))) return true;
}
return false;
},
/** Seed from /api/auth/me credits (preferred primary source per contract). */
hydrateFromCredits(credits?: CreditsLike | null) {
applyCredits(credits);
@@ -78,6 +105,7 @@ export const planCapabilities = {
statusState = "idle";
etagState = null;
planNameState = null;
memberDeniedState = new Set();
fetchedOnce = false;
fetchedAtMs = 0;
},
+4
View File
@@ -18,6 +18,10 @@ export type CapabilitiesResponse = {
sections?: Record<string, boolean>;
disabled_features?: string[];
feature_etag?: string;
/** True when the company owner narrowed this member's access (see settings > team). */
member_restricted?: boolean;
/** Keys the plan allows but the member overlay denies — "ask your admin", not "upgrade". */
member_denied_features?: string[];
entitlements?: {
can_use_ai?: boolean;
can_use_eprel?: boolean;
+108
View File
@@ -0,0 +1,108 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { coerceImageUrl, coerceImageUrlList, productImageUrls } from "./product-images.ts";
describe("coerceImageUrl", () => {
it("keeps absolute http(s) URLs and upgrades protocol-relative ones", () => {
assert.equal(coerceImageUrl("https://cdn.example/a.jpg"), "https://cdn.example/a.jpg");
assert.equal(coerceImageUrl(" http://cdn.example/a.jpg "), "http://cdn.example/a.jpg");
assert.equal(coerceImageUrl("//cdn.example/a.jpg"), "https://cdn.example/a.jpg");
});
it("rejects empties, relative paths and the [object Object] sentinel", () => {
assert.equal(coerceImageUrl(""), "");
assert.equal(coerceImageUrl(" "), "");
assert.equal(coerceImageUrl("/media/a.jpg"), "");
assert.equal(coerceImageUrl("[object Object]"), "");
assert.equal(coerceImageUrl(null), "");
assert.equal(coerceImageUrl(42), "");
});
it("unwraps XML/JSON node objects and arrays", () => {
assert.equal(coerceImageUrl({ "#text": "https://cdn.example/a.jpg" }), "https://cdn.example/a.jpg");
assert.equal(coerceImageUrl({ "@_url": "https://cdn.example/b.jpg" }), "https://cdn.example/b.jpg");
assert.equal(coerceImageUrl({ src: "https://cdn.example/c.jpg" }), "https://cdn.example/c.jpg");
assert.equal(coerceImageUrl(["", "https://cdn.example/d.jpg"]), "https://cdn.example/d.jpg");
});
});
describe("coerceImageUrlList", () => {
it("splits the comma-separated form supplier feeds use for moreimages", () => {
assert.deepEqual(
coerceImageUrlList("https://cdn.example/1.jpg,https://cdn.example/2.jpg"),
["https://cdn.example/1.jpg", "https://cdn.example/2.jpg"]
);
});
it("handles single values, arrays and junk entries", () => {
assert.deepEqual(coerceImageUrlList("https://cdn.example/1.jpg"), ["https://cdn.example/1.jpg"]);
assert.deepEqual(coerceImageUrlList(["https://cdn.example/1.jpg", "nope", ""]), [
"https://cdn.example/1.jpg"
]);
assert.deepEqual(coerceImageUrlList(""), []);
assert.deepEqual(coerceImageUrlList(null), []);
});
});
describe("productImageUrls", () => {
it("returns main_image first, then the comma-joined moreimages list", () => {
const product = {
mapped_data: {
main_image: "https://cdn.example/105180.jpg",
moreimages: "https://cdn.example/105180_1.jpg,https://cdn.example/105180_2.jpg"
}
};
assert.deepEqual(productImageUrls(product), [
"https://cdn.example/105180.jpg",
"https://cdn.example/105180_1.jpg",
"https://cdn.example/105180_2.jpg"
]);
});
it("falls back to additional_image_urls when main_image is an empty string", () => {
const product = {
mapped_data: {
main_image: "",
additional_image_urls: "https://cdn.example/a.jpg,https://cdn.example/b.jpg"
}
};
assert.deepEqual(productImageUrls(product), [
"https://cdn.example/a.jpg",
"https://cdn.example/b.jpg"
]);
});
it("reads the v1 detail DTO shape from the row itself", () => {
const product = {
main_image: "https://cdn.example/main.jpg",
more_images: ["https://cdn.example/more1.jpg", "https://cdn.example/more2.jpg"]
};
assert.deepEqual(productImageUrls(product), [
"https://cdn.example/main.jpg",
"https://cdn.example/more1.jpg",
"https://cdn.example/more2.jpg"
]);
});
it("dedupes across bags and keys", () => {
const product = {
mapped_data: {
image_url: "https://cdn.example/a.jpg",
main_image: "https://cdn.example/a.jpg",
images: ["https://cdn.example/a.jpg", "https://cdn.example/b.jpg"]
},
main_image: "https://cdn.example/a.jpg"
};
assert.deepEqual(productImageUrls(product), [
"https://cdn.example/a.jpg",
"https://cdn.example/b.jpg"
]);
});
it("is empty for products with no usable image values", () => {
assert.deepEqual(productImageUrls(null), []);
assert.deepEqual(productImageUrls({}), []);
assert.deepEqual(productImageUrls({ mapped_data: { main_image: "" } }), []);
assert.deepEqual(productImageUrls({ mapped_data: "not-an-object" }), []);
});
});
+141
View File
@@ -0,0 +1,141 @@
/**
* Product image extraction for the dashboard.
*
* Mirrors `catalog.ExtractProductImages` (apps/api/internal/catalog/raw_v1.go) key-for-key.
* Supplier feeds are inconsistent: `main_image` is frequently empty while `moreimages`
* (no underscore) or `additional_image_urls` carries a comma-separated list, so any
* divergence from the Go key sets makes the product editor render nothing.
*/
/** Minimal shape needed here — the full row type lives in components/products/types.ts. */
export type ProductImageSource = Record<string, unknown> | null | undefined;
/** Main-image keys, in precedence order (matches Go mainKeys + the legacy `image` alias). */
export const MAIN_IMAGE_KEYS = [
"image_url",
"main_image",
"image_link",
"mainImage",
"MainImage",
"imageUrl",
"imageLink",
"ImageLink",
"image"
] as const;
/** Additional-image keys (matches Go moreKeys + the legacy `additional_images` alias). */
export const MORE_IMAGE_KEYS = [
"additional_image_urls",
"additional_image_link",
"more_images",
"moreImages",
"MoreImages",
"moreimages",
"additionalImageLink",
"additionalImageUrls",
"additional_images"
] as const;
/** Keys used by XML/JSON feeds that wrap a URL inside an object node. */
const NESTED_URL_KEYS = [
"#text",
"__cdata",
"@_href",
"@_url",
"href",
"url",
"src",
"link",
"image"
];
function asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
return value as Record<string, unknown>;
}
/** Normalize one value to an absolute http(s) URL, or "" (Go: coerceToURLString). */
export function coerceImageUrl(value: unknown): string {
if (value == null) return "";
if (typeof value === "string") {
const trimmed = value.trim();
if (trimmed === "" || trimmed === "[object Object]") return "";
// Protocol-relative CDN URLs (//cdn.example/x.jpg) are common in supplier feeds.
if (trimmed.startsWith("//")) return `https:${trimmed}`;
return /^https?:\/\//i.test(trimmed) ? trimmed : "";
}
if (Array.isArray(value)) {
for (const entry of value) {
const url = coerceImageUrl(entry);
if (url) return url;
}
return "";
}
const rec = asRecord(value);
if (!rec) return "";
for (const key of NESTED_URL_KEYS) {
const url = coerceImageUrl(rec[key]);
if (url) return url;
}
return "";
}
/** Normalize one value to a URL list, splitting comma-joined feed strings (Go: coerceToURLList). */
export function coerceImageUrlList(value: unknown): string[] {
if (value == null) return [];
if (typeof value === "string") {
const trimmed = value.trim();
if (trimmed === "") return [];
if (trimmed.includes(",")) {
return trimmed
.split(",")
.map((part) => coerceImageUrl(part))
.filter((url) => url !== "");
}
const url = coerceImageUrl(trimmed);
return url ? [url] : [];
}
if (Array.isArray(value)) {
return value.map((entry) => coerceImageUrl(entry)).filter((url) => url !== "");
}
const url = coerceImageUrl(value);
return url ? [url] : [];
}
/**
* Collect http(s) product image URLs in display order (main image first, deduped).
*
* Bags are scanned in precedence order: `mapped_data` (the feed payload), the product row
* itself (the v1 detail DTO exposes top-level main_image / more_images), then the
* processed / raw attribute bags.
*/
export function productImageUrls(product: ProductImageSource): string[] {
const row = asRecord(product);
if (!row) return [];
const bags = [
asRecord(row.mapped_data),
row,
asRecord(row.processed_attributes),
asRecord(row.attributes)
].filter((bag): bag is Record<string, unknown> => bag != null);
const seen = new Set<string>();
const out: string[] = [];
const add = (url: string) => {
if (!url || seen.has(url)) return;
seen.add(url);
out.push(url);
};
for (const bag of bags) {
for (const key of MAIN_IMAGE_KEYS) {
if (bag[key] != null) add(coerceImageUrl(bag[key]));
}
for (const key of MORE_IMAGE_KEYS) {
if (bag[key] != null) coerceImageUrlList(bag[key]).forEach(add);
}
// `images` may hold the main image plus the rest as a single list.
if (bag.images != null) coerceImageUrlList(bag.images).forEach(add);
}
return out;
}
+5 -1
View File
@@ -76,7 +76,11 @@ export function contentSecurityPolicy(opts: ContentSecurityPolicyOptions): strin
connect.push(opts.apiOrigin);
}
const fontSrc = "font-src 'self' data:";
const imgSrc = joinSrc("img-src 'self' data: blob:", gtmImg);
// https: — product images come from arbitrary supplier CDNs resolved at feed-sync
// time (mapped_data.main_image / moreimages), so the host set cannot be allowlisted.
// Images only: no script/style/connect relaxation, and referrerpolicy="no-referrer"
// on the <img> tags keeps dashboard URLs out of supplier logs.
const imgSrc = joinSrc("img-src 'self' data: blob: https:", gtmImg);
const frameSrc = joinSrc("frame-src 'self'", gtmFrame);
if (opts.dev) {
+4
View File
@@ -238,6 +238,10 @@ export type TeamMember = {
status?: string | null;
created_at?: string | null;
is_owner?: boolean;
/** True when the company owner narrowed this member's dashboard access. */
restricted?: boolean;
/** How many areas are turned off for this member (0 = full plan access). */
denied_count?: number;
};
export type ChannelSyncSummary = {
+65 -5
View File
@@ -64,6 +64,7 @@
} from "$lib/types";
import PageShell from "$lib/components/PageShell.svelte";
import Alert from "$lib/components/Alert.svelte";
import MemberPermissionsDialog from "$lib/components/settings/MemberPermissionsDialog.svelte";
import Spinner from "$lib/components/Spinner.svelte";
import EmptyState from "$lib/components/EmptyState.svelte";
import ForbiddenEmptyState from "$lib/components/ForbiddenEmptyState.svelte";
@@ -113,6 +114,18 @@
return settingsTabAllowed(tab, (key) => planCapabilities.can(key));
}
/**
* Why a tab is locked: an owner-set member restriction reads differently from a plan
* limit — "upgrade" would be misleading advice when only an admin can re-enable it.
*/
function tabLockedHint(tab: Tab): string {
const key = SETTINGS_TAB_FEATURES[tab];
if (key && planCapabilities.memberDenied(key)) {
return i18n.t("settings.tabLockedByAdmin");
}
return i18n.t("settings.tabLockedHint");
}
function firstAllowedTab(): Tab {
return TAB_ORDER.find((t) => tabAllowed(t)) ?? "profile";
}
@@ -187,6 +200,9 @@
let pendingInvites = $state<PendingInvite[]>([]);
let canAdmin = $state(false);
let canOwner = $state(false);
/** Per-member access overlay editor (owner-only edits; admins get a read-only view). */
let permissionsOpen = $state(false);
let permissionsMember = $state<TeamMember | null>(null);
let accessDenied = $state(false);
let teamForbidden = $state(false);
let apiKeysForbidden = $state(false);
@@ -843,6 +859,24 @@
return (member.user_id ?? member.id) === user.id;
}
function openPermissions(member: TeamMember) {
permissionsMember = member;
permissionsOpen = true;
}
/** Reflect the saved overlay in the team table without refetching the whole list. */
function applyPermissionsSaved(view: { user_id: string; denied: string[] }) {
const count = view.denied?.length ?? 0;
team = team.map((m) =>
(m.user_id ?? m.id) === view.user_id
? { ...m, restricted: count > 0, denied_count: count }
: m
);
success = i18n.t("settings.permissions.saved", {
email: team.find((m) => (m.user_id ?? m.id) === view.user_id)?.email ?? ""
});
}
function roleLabel(role?: string | null): string {
return role === "admin" ? i18n.t("settings.role.admin") : i18n.t("settings.role.member");
}
@@ -880,7 +914,7 @@
<span class="truncate">{i18n.t("settings.tab.profile")}</span>
{#if !tabAllowed("profile")}
<Lock class="ml-auto h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden="true" />
<span class="sr-only">{i18n.t("settings.tabLockedHint")}</span>
<span class="sr-only">{tabLockedHint("profile")}</span>
{/if}
</TabsTrigger>
<TabsTrigger value="company" class="shrink-0 justify-start md:w-full">
@@ -888,7 +922,7 @@
<span class="truncate">{i18n.t("settings.tab.company")}</span>
{#if !tabAllowed("company")}
<Lock class="ml-auto h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden="true" />
<span class="sr-only">{i18n.t("settings.tabLockedHint")}</span>
<span class="sr-only">{tabLockedHint("company")}</span>
{/if}
</TabsTrigger>
<TabsTrigger value="alerts" class="shrink-0 justify-start md:w-full">
@@ -896,7 +930,7 @@
<span class="truncate">{i18n.t("settings.tab.alerts")}</span>
{#if !tabAllowed("alerts")}
<Lock class="ml-auto h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden="true" />
<span class="sr-only">{i18n.t("settings.tabLockedHint")}</span>
<span class="sr-only">{tabLockedHint("alerts")}</span>
{/if}
</TabsTrigger>
<TabsTrigger value="api-keys" class="shrink-0 justify-start md:w-full" data-testid="settings-tab-api-keys">
@@ -904,7 +938,7 @@
<span class="truncate">{i18n.t("settings.tab.apiKeys")}</span>
{#if !tabAllowed("api-keys")}
<Lock class="ml-auto h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden="true" />
<span class="sr-only">{i18n.t("settings.tabLockedHint")}</span>
<span class="sr-only">{tabLockedHint("api-keys")}</span>
{/if}
</TabsTrigger>
<TabsTrigger value="team" class="shrink-0 justify-start md:w-full">
@@ -912,7 +946,7 @@
<span class="truncate">{i18n.t("settings.tab.team")}</span>
{#if !tabAllowed("team")}
<Lock class="ml-auto h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden="true" />
<span class="sr-only">{i18n.t("settings.tabLockedHint")}</span>
<span class="sr-only">{tabLockedHint("team")}</span>
{/if}
</TabsTrigger>
</TabsList>
@@ -1644,6 +1678,16 @@
{#if member.is_owner}
<Badge variant="secondary">{i18n.t("settings.ownerBadge")}</Badge>
{/if}
{#if member.restricted}
<Badge
variant="outline"
title={i18n.t("settings.permissions.restrictedHint", {
count: member.denied_count ?? 0
})}
>
{i18n.t("settings.permissions.restrictedBadge")}
</Badge>
{/if}
</div>
</TableCell>
<TableCell>
@@ -1680,6 +1724,9 @@
<MoreVertical class="h-4 w-4" />
</Button>
{/snippet}
<DropdownMenuItem onclick={() => openPermissions(member)}>
{i18n.t("settings.permissions.action")}
</DropdownMenuItem>
{#if member.role === "admin"}
<DropdownMenuItem
disabled={saving || !canDemoteMember(member)}
@@ -1953,3 +2000,16 @@
{/snippet}
</Dialog>
<!-- Per-member permissions -->
<MemberPermissionsDialog
bind:open={permissionsOpen}
member={permissionsMember
? {
user_id: String(permissionsMember.user_id ?? permissionsMember.id),
email: permissionsMember.email,
is_owner: permissionsMember.is_owner
}
: null}
canEdit={canOwner}
onSaved={applyPermissionsSaved}
/>