major fixes
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
};
|
||||
|
||||
@@ -5667,4 +5667,25 @@ export const fr: MessageDict = {
|
||||
"files.empty": "Aucun téléversement pour l’instant.",
|
||||
"products.filters.sortProducts": "Trier les produits",
|
||||
"products.table.options": "Options",
|
||||
"access.restricted.title": "Accès restreint",
|
||||
"access.restricted.message": "Le propriétaire de votre entreprise a désactivé l’accès à cette section. Demandez-lui de l’activer si vous en avez besoin.",
|
||||
"access.restricted.featureHint": "Autorisation : {feature}",
|
||||
"settings.permissions.action": "Autorisations",
|
||||
"settings.permissions.title": "Autorisations de {email}",
|
||||
"settings.permissions.description": "Choisissez ce que ce membre peut ouvrir. Désactiver une section la masque dans sa barre latérale et la bloque dans l’API.",
|
||||
"settings.permissions.ownerNotice": "Le propriétaire de l’entreprise a toujours un accès complet et ne peut pas être restreint.",
|
||||
"settings.permissions.ownerOnly": "Seul le propriétaire de l’entreprise peut modifier les autorisations.",
|
||||
"settings.permissions.summary": "{allowed} sur {total} sections autorisées",
|
||||
"settings.permissions.allowAll": "Tout autoriser",
|
||||
"settings.permissions.denyAll": "Tout refuser",
|
||||
"settings.permissions.planLocked": "Absent de votre forfait",
|
||||
"settings.permissions.searchPlaceholder": "Rechercher des sections…",
|
||||
"settings.permissions.noMatches": "Aucune section ne correspond à cette recherche.",
|
||||
"settings.permissions.save": "Enregistrer les autorisations",
|
||||
"settings.permissions.saved": "Autorisations mises à jour pour {email}",
|
||||
"settings.permissions.saveFailed": "Impossible de mettre à jour les autorisations",
|
||||
"settings.permissions.loadFailed": "Impossible de charger les autorisations",
|
||||
"settings.permissions.restrictedBadge": "Restreint",
|
||||
"settings.permissions.restrictedHint": "{count} sections désactivées",
|
||||
"settings.tabLockedByAdmin": "Désactivé par le propriétaire de votre entreprise"
|
||||
};
|
||||
|
||||
@@ -5667,4 +5667,25 @@ export const it: MessageDict = {
|
||||
"files.empty": "Nessun caricamento ancora.",
|
||||
"products.filters.sortProducts": "Ordina prodotti",
|
||||
"products.table.options": "Opzioni",
|
||||
"access.restricted.title": "Accesso limitato",
|
||||
"access.restricted.message": "Il proprietario della tua azienda ha disattivato l’accesso a questa sezione. Chiedigli di abilitarla se ti serve.",
|
||||
"access.restricted.featureHint": "Autorizzazione: {feature}",
|
||||
"settings.permissions.action": "Autorizzazioni",
|
||||
"settings.permissions.title": "Autorizzazioni per {email}",
|
||||
"settings.permissions.description": "Scegli cosa può aprire questo membro del team. Disattivare una sezione la nasconde dalla barra laterale e la blocca nell’API.",
|
||||
"settings.permissions.ownerNotice": "Il proprietario dell’azienda ha sempre accesso completo e non può essere limitato.",
|
||||
"settings.permissions.ownerOnly": "Solo il proprietario dell’azienda può modificare le autorizzazioni.",
|
||||
"settings.permissions.summary": "{allowed} di {total} sezioni consentite",
|
||||
"settings.permissions.allowAll": "Consenti tutto",
|
||||
"settings.permissions.denyAll": "Nega tutto",
|
||||
"settings.permissions.planLocked": "Non incluso nel tuo piano",
|
||||
"settings.permissions.searchPlaceholder": "Cerca sezioni…",
|
||||
"settings.permissions.noMatches": "Nessuna sezione corrisponde alla ricerca.",
|
||||
"settings.permissions.save": "Salva autorizzazioni",
|
||||
"settings.permissions.saved": "Autorizzazioni aggiornate per {email}",
|
||||
"settings.permissions.saveFailed": "Impossibile aggiornare le autorizzazioni",
|
||||
"settings.permissions.loadFailed": "Impossibile caricare le autorizzazioni",
|
||||
"settings.permissions.restrictedBadge": "Limitato",
|
||||
"settings.permissions.restrictedHint": "{count} sezioni disattivate",
|
||||
"settings.tabLockedByAdmin": "Disattivato dal proprietario della tua azienda"
|
||||
};
|
||||
|
||||
@@ -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": "会社のオーナーが無効にしています"
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
};
|
||||
|
||||
@@ -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"]
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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" }), []);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user