fix
This commit is contained in:
@@ -602,6 +602,9 @@ func (s *Service) EnsureDefaultPlans(ctx context.Context) error {
|
||||
if err := s.EnsureDefaultFeatureSeeds(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.EnsureSuperAdminPlan(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.EnsurePlanCatalogHygiene(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package billing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// SuperAdminPlanName is an internal assignable plan: every dashboard feature ON,
|
||||
// unlimited SKUs, and the Enterprise-sized credit grant. Not on the public ladder.
|
||||
const SuperAdminPlanName = "Super Admin"
|
||||
|
||||
// IsSuperAdminPlan reports the fully unlocked internal plan (assign via admin billing).
|
||||
func IsSuperAdminPlan(name string) bool {
|
||||
return strings.EqualFold(strings.TrimSpace(name), SuperAdminPlanName)
|
||||
}
|
||||
|
||||
// EnsureSuperAdminPlan upserts the Super Admin plan row (is_custom=true → enable-all features).
|
||||
func (s *Service) EnsureSuperAdminPlan(ctx context.Context) error {
|
||||
if s == nil || s.Pool == nil {
|
||||
return errors.New("billing service unavailable")
|
||||
}
|
||||
desc := "Internal unlock — every feature enabled, unlimited SKUs, large AI grant (not sold publicly)"
|
||||
var id int64
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
SELECT id FROM plans WHERE lower(name) = lower($1) ORDER BY id LIMIT 1`,
|
||||
SuperAdminPlanName).Scan(&id)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
_, err = s.Pool.Exec(ctx, `
|
||||
INSERT INTO plans (name, description, monthly_credits, yearly_credits, max_products, is_custom, term)
|
||||
VALUES ($1, $2, $3, NULL, NULL, true, 'monthly')`,
|
||||
SuperAdminPlanName, desc, EnterpriseUnlimitedCredits)
|
||||
return err
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = s.Pool.Exec(ctx, `
|
||||
UPDATE plans SET description = $2, monthly_credits = $3, max_products = NULL,
|
||||
is_custom = true, term = 'monthly', updated_at = now()
|
||||
WHERE id = $1`, id, desc, EnterpriseUnlimitedCredits)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package billing
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestIsSuperAdminPlan(t *testing.T) {
|
||||
t.Parallel()
|
||||
if !IsSuperAdminPlan("Super Admin") || !IsSuperAdminPlan("super admin") {
|
||||
t.Fatal("expected Super Admin name match")
|
||||
}
|
||||
if IsSuperAdminPlan("Enterprise") || IsSuperAdminPlan("") {
|
||||
t.Fatal("Enterprise / empty must not match Super Admin")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuperAdminPlanNotPublic(t *testing.T) {
|
||||
t.Parallel()
|
||||
if IsPublicProductPlan(SuperAdminPlanName) {
|
||||
t.Fatal("Super Admin must stay off the public pricing ladder")
|
||||
}
|
||||
if !IsCustomPackage(SuperAdminPlanName, true) {
|
||||
t.Fatal("Super Admin with is_custom must unlock via custom package defaults")
|
||||
}
|
||||
}
|
||||
@@ -16,10 +16,14 @@
|
||||
import { Badge, Progress, buttonClasses } from "$lib/components/ui";
|
||||
import { ChevronDown, ChevronUp, Layers } from "@lucide/svelte";
|
||||
|
||||
const POLL_MS = 2_000;
|
||||
const RETRY_MS = 10_000;
|
||||
const POLL_ACTIVE_MS = 5_000;
|
||||
/** Idle poll is intentionally slow — the float is only useful when jobs are running. */
|
||||
const POLL_IDLE_MS = 30_000;
|
||||
const RETRY_MS = 15_000;
|
||||
/** Keep the float visible briefly after the last active job finishes (UX + short E2E windows). */
|
||||
const LINGER_MS = 6_000;
|
||||
const IDLE_JOBS_LIMIT = 15;
|
||||
const ACTIVE_JOBS_LIMIT = 30;
|
||||
|
||||
let jobs = $state<ProcessingJob[]>([]);
|
||||
let displayJobs = $state<ProcessingJob[]>([]);
|
||||
@@ -135,10 +139,12 @@
|
||||
|
||||
async function refresh(signal?: AbortSignal) {
|
||||
const myGen = ++gen;
|
||||
const limit = activeJobs.length > 0 ? ACTIVE_JOBS_LIMIT : IDLE_JOBS_LIMIT;
|
||||
try {
|
||||
const payload = await api<ListResponse<ProcessingJob>>("/api/processing/jobs?limit=50", {
|
||||
signal
|
||||
});
|
||||
const payload = await api<ListResponse<ProcessingJob>>(
|
||||
`/api/processing/jobs?limit=${limit}`,
|
||||
{ signal }
|
||||
);
|
||||
if (myGen !== gen) return true;
|
||||
jobs = unwrapList(payload);
|
||||
syncDisplay(jobs);
|
||||
@@ -162,6 +168,11 @@
|
||||
}, ms);
|
||||
}
|
||||
|
||||
function nextPollMs(ok: boolean): number {
|
||||
if (!ok) return RETRY_MS;
|
||||
return activeJobs.length > 0 ? POLL_ACTIVE_MS : POLL_IDLE_MS;
|
||||
}
|
||||
|
||||
async function tick() {
|
||||
if (document.visibilityState === "hidden") return;
|
||||
abort?.abort();
|
||||
@@ -169,7 +180,7 @@
|
||||
abort = ac;
|
||||
const ok = await refresh(ac.signal);
|
||||
if (ac.signal.aborted) return;
|
||||
schedule(ok ? POLL_MS : RETRY_MS);
|
||||
schedule(nextPollMs(ok));
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
@@ -178,7 +189,7 @@
|
||||
void (async () => {
|
||||
const ok = await refresh(ac.signal);
|
||||
if (ac.signal.aborted) return;
|
||||
schedule(ok ? POLL_MS : RETRY_MS);
|
||||
schedule(nextPollMs(ok));
|
||||
})();
|
||||
|
||||
const onVis = () => {
|
||||
|
||||
@@ -19,9 +19,11 @@
|
||||
roleById,
|
||||
rolePresetDenied,
|
||||
samePermissions,
|
||||
sectionPermissionTree,
|
||||
setPermissionAllowed,
|
||||
type MemberPermissionsView,
|
||||
type PermissionCatalog
|
||||
type PermissionCatalog,
|
||||
type PermissionCatalogEntry
|
||||
} from "$lib/member-permissions";
|
||||
|
||||
let {
|
||||
@@ -48,6 +50,8 @@
|
||||
let isOwnerMember = $state(false);
|
||||
/** Raw per-key checkboxes stay collapsed — the role picker is the primary control. */
|
||||
let advancedOpen = $state(false);
|
||||
/** Which root keys have their fine-tune children expanded. */
|
||||
let fineTuneOpen = $state<Record<string, boolean>>({});
|
||||
|
||||
const allKeys = $derived(catalogKeys(catalog));
|
||||
const summary = $derived(permissionSummary(catalog, denied));
|
||||
@@ -76,6 +80,7 @@
|
||||
}
|
||||
|
||||
const visibleSections = $derived(filterCatalog(catalog, search, labelFor));
|
||||
const searching = $derived(search.trim().length > 0);
|
||||
|
||||
/** Reload whenever the dialog opens for a member (never on every keystroke). */
|
||||
$effect(() => {
|
||||
@@ -86,6 +91,7 @@
|
||||
loadError = null;
|
||||
search = "";
|
||||
advancedOpen = false;
|
||||
fineTuneOpen = {};
|
||||
void (async () => {
|
||||
try {
|
||||
const [catalogPayload, view] = await Promise.all([
|
||||
@@ -121,6 +127,14 @@
|
||||
denied = denyAll(catalog);
|
||||
}
|
||||
|
||||
function toggleFineTune(key: string) {
|
||||
fineTuneOpen = { ...fineTuneOpen, [key]: !fineTuneOpen[key] };
|
||||
}
|
||||
|
||||
function entryAllowed(entry: PermissionCatalogEntry): boolean {
|
||||
return entry.plan_allowed && !isDenied(denied, entry.key);
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!member || readOnly || saving) return;
|
||||
saving = true;
|
||||
@@ -142,13 +156,6 @@
|
||||
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
|
||||
@@ -237,6 +244,7 @@
|
||||
|
||||
{#if advancedOpen}
|
||||
<div class="mt-3 space-y-3">
|
||||
<p class="text-xs text-muted-foreground">{i18n.t("settings.permissions.fineTuneHint")}</p>
|
||||
{#if !readOnly}
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" data-testid="permissions-allow-all" onclick={allowEverything}>
|
||||
@@ -255,7 +263,7 @@
|
||||
aria-label={i18n.t("settings.permissions.searchPlaceholder")}
|
||||
/>
|
||||
|
||||
<div class="max-h-[20rem] space-y-5 overflow-y-auto pr-1">
|
||||
<div class="max-h-[22rem] space-y-4 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")}
|
||||
@@ -264,18 +272,15 @@
|
||||
{#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"
|
||||
class="sticky top-0 z-10 bg-background py-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground"
|
||||
>
|
||||
{sectionLabel(section.id)}
|
||||
</h3>
|
||||
{#if searching}
|
||||
{#each section.entries as entry (entry.key)}
|
||||
{@const allowed = entry.plan_allowed && !isDenied(denied, entry.key)}
|
||||
{@const allowed = entryAllowed(entry)}
|
||||
{@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
|
||||
)}"
|
||||
>
|
||||
<div class="flex items-center gap-3 rounded-md px-2 py-1.5 hover:bg-muted/40">
|
||||
<Checkbox
|
||||
id={`perm-${entry.key}`}
|
||||
checked={allowed}
|
||||
@@ -297,6 +302,84 @@
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{:else}
|
||||
{#each sectionPermissionTree(section) as node (node.root.key)}
|
||||
{@const root = node.root}
|
||||
{@const allowed = entryAllowed(root)}
|
||||
{@const locked = !root.plan_allowed}
|
||||
{@const hasChildren = node.children.length > 0}
|
||||
{@const childrenOpen = Boolean(fineTuneOpen[root.key])}
|
||||
<div class="rounded-md border border-transparent hover:border-border/60">
|
||||
<div class="flex items-center gap-2 px-2 py-1.5">
|
||||
<Checkbox
|
||||
id={`perm-${root.key}`}
|
||||
checked={allowed}
|
||||
disabled={readOnly || locked}
|
||||
aria-label={labelFor(root.key)}
|
||||
data-testid={`perm-${root.key}`}
|
||||
onchange={() => toggle(root.key, !allowed)}
|
||||
/>
|
||||
<label
|
||||
for={`perm-${root.key}`}
|
||||
class="min-w-0 flex-1 cursor-pointer text-sm font-medium {locked
|
||||
? 'text-muted-foreground'
|
||||
: 'text-foreground'}"
|
||||
>
|
||||
{labelFor(root.key)}
|
||||
</label>
|
||||
{#if locked}
|
||||
<Badge variant="secondary">{i18n.t("settings.permissions.planLocked")}</Badge>
|
||||
{/if}
|
||||
{#if hasChildren}
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 rounded px-1.5 py-0.5 text-xs text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
aria-expanded={childrenOpen}
|
||||
data-testid={`perm-fine-${root.key}`}
|
||||
onclick={() => toggleFineTune(root.key)}
|
||||
>
|
||||
{childrenOpen
|
||||
? i18n.t("settings.permissions.hideFineTune")
|
||||
: i18n.t("settings.permissions.showFineTune", {
|
||||
count: node.children.length
|
||||
})}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if hasChildren && childrenOpen}
|
||||
<div class="mb-1 ml-4 space-y-0.5 border-l border-border/70 pl-3">
|
||||
{#each node.children as entry (entry.key)}
|
||||
{@const childAllowed = entryAllowed(entry)}
|
||||
{@const childLocked = !entry.plan_allowed}
|
||||
<div class="flex items-center gap-3 rounded-md px-1 py-1 hover:bg-muted/40">
|
||||
<Checkbox
|
||||
id={`perm-${entry.key}`}
|
||||
checked={childAllowed}
|
||||
disabled={readOnly || childLocked || isDenied(denied, root.key)}
|
||||
aria-label={labelFor(entry.key)}
|
||||
data-testid={`perm-${entry.key}`}
|
||||
onchange={() => toggle(entry.key, !childAllowed)}
|
||||
/>
|
||||
<label
|
||||
for={`perm-${entry.key}`}
|
||||
class="min-w-0 flex-1 cursor-pointer text-sm {childLocked
|
||||
? 'text-muted-foreground'
|
||||
: 'text-foreground'}"
|
||||
>
|
||||
{labelFor(entry.key)}
|
||||
</label>
|
||||
{#if childLocked}
|
||||
<Badge variant="secondary"
|
||||
>{i18n.t("settings.permissions.planLocked")}</Badge
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</section>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -5695,6 +5695,8 @@ export const de: MessageDict = {
|
||||
"settings.permissions.role.full.desc": "Alles, was Ihr Tarif enthält.",
|
||||
"settings.permissions.role.manager": "Manager",
|
||||
"settings.permissions.role.manager.desc": "Alles außer Abrechnung, Team und API-Schlüsseln.",
|
||||
"settings.permissions.role.integrator": "Third-party developer",
|
||||
"settings.permissions.role.integrator.desc": "Products, categories, attributes, fields, feeds, exports, processing and API keys — for system integrations. Fine-tune to turn any area off.",
|
||||
"settings.permissions.role.catalog_editor": "Katalogpflege",
|
||||
"settings.permissions.role.catalog_editor.desc": "Produkte, Kategorien, Attribute und Felder. Keine Feeds, Shops oder Einstellungen.",
|
||||
"settings.permissions.role.feed_operator": "Feed-Betrieb",
|
||||
|
||||
@@ -5790,6 +5790,8 @@ export const en: MessageDict = {
|
||||
"settings.permissions.role.full.desc": "Everything your plan includes.",
|
||||
"settings.permissions.role.manager": "Manager",
|
||||
"settings.permissions.role.manager.desc": "Everything except billing, teammates and API keys.",
|
||||
"settings.permissions.role.integrator": "Third-party developer",
|
||||
"settings.permissions.role.integrator.desc": "Products, categories, attributes, fields, feeds, exports, processing and API keys — for system integrations. Fine-tune to turn any area off.",
|
||||
"settings.permissions.role.catalog_editor": "Catalog editor",
|
||||
"settings.permissions.role.catalog_editor.desc": "Products, categories, attributes and fields. No feeds, stores or settings.",
|
||||
"settings.permissions.role.feed_operator": "Feed operator",
|
||||
@@ -5801,6 +5803,9 @@ export const en: MessageDict = {
|
||||
"settings.permissions.role.custom": "Custom",
|
||||
"settings.permissions.role.custom.desc": "You picked areas by hand, so this matches no standard role.",
|
||||
"settings.permissions.advanced": "Fine-tune individual areas",
|
||||
"settings.permissions.fineTuneHint": "Areas only — expand Fine-tune when you need tabs or actions inside an area.",
|
||||
"settings.permissions.showFineTune": "Fine-tune ({count})",
|
||||
"settings.permissions.hideFineTune": "Hide fine-tune",
|
||||
"admin.nav.aiCalls": "AI calls",
|
||||
"admin.aiCalls.title": "AI calls",
|
||||
"admin.aiCalls.description": "The exact prompt and response of every LLM call. Kept for {days} days.",
|
||||
|
||||
+2475
-2472
File diff suppressed because it is too large
Load Diff
+3248
-3245
File diff suppressed because it is too large
Load Diff
+1000
-997
File diff suppressed because it is too large
Load Diff
+5307
-5304
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
+3293
-3290
File diff suppressed because it is too large
Load Diff
+2700
-2697
File diff suppressed because it is too large
Load Diff
@@ -1,290 +1,97 @@
|
||||
import assert from "node:assert/strict";
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import {
|
||||
catalogKeys,
|
||||
denyAll,
|
||||
featureAncestors,
|
||||
filterCatalog,
|
||||
isDenied,
|
||||
permissionSummary,
|
||||
samePermissions,
|
||||
setPermissionAllowed,
|
||||
CUSTOM_ROLE_ID,
|
||||
PERMISSION_ROLES,
|
||||
childEntriesOf,
|
||||
detectRole,
|
||||
roleById,
|
||||
rolePresetDenied,
|
||||
sectionPermissionTree,
|
||||
type PermissionCatalog
|
||||
} from "./member-permissions.ts";
|
||||
|
||||
const catalog: PermissionCatalog = {
|
||||
const sampleCatalog: PermissionCatalog = {
|
||||
sections: [
|
||||
{
|
||||
id: "catalog",
|
||||
entries: [
|
||||
{ key: "catalog.products", section: "catalog", parent: "", plan_allowed: true },
|
||||
{ key: "catalog.products", section: "catalog", plan_allowed: true },
|
||||
{
|
||||
key: "catalog.products.tab_error",
|
||||
key: "catalog.products.tab_unprocessed",
|
||||
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"]
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
/** A catalog wide enough to exercise section-level role grants. */
|
||||
const roleCatalog: PermissionCatalog = {
|
||||
sections: [
|
||||
{
|
||||
id: "dashboard",
|
||||
entries: [{ key: "dashboard.stats", section: "dashboard", parent: "", plan_allowed: true }]
|
||||
},
|
||||
{
|
||||
id: "catalog",
|
||||
entries: [
|
||||
{ key: "catalog.products", section: "catalog", parent: "", plan_allowed: true },
|
||||
{
|
||||
key: "catalog.products.tab_error",
|
||||
section: "catalog",
|
||||
parent: "catalog.products",
|
||||
plan_allowed: true
|
||||
},
|
||||
{ key: "catalog.categories", section: "catalog", parent: "", plan_allowed: true }
|
||||
{ key: "catalog.categories", section: "catalog", plan_allowed: true }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "feeds",
|
||||
entries: [
|
||||
{ key: "feeds.list", section: "feeds", parent: "", plan_allowed: true },
|
||||
{ key: "feeds.export_feeds", section: "feeds", parent: "", plan_allowed: true }
|
||||
{ key: "feeds.list", section: "feeds", plan_allowed: true },
|
||||
{ key: "feeds.export_feeds", section: "feeds", plan_allowed: true }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "marketing",
|
||||
entries: [{ key: "marketing.campaigns", section: "marketing", parent: "", plan_allowed: true }]
|
||||
},
|
||||
{
|
||||
id: "integrations",
|
||||
entries: [{ key: "integrations.email", section: "integrations", parent: "", plan_allowed: true }]
|
||||
},
|
||||
{
|
||||
id: "billing",
|
||||
entries: [
|
||||
{ key: "billing.overview", section: "billing", parent: "", plan_allowed: true },
|
||||
{ key: "billing.checkout", section: "billing", parent: "", plan_allowed: true }
|
||||
]
|
||||
id: "processing",
|
||||
entries: [{ key: "processing.monitor", section: "processing", plan_allowed: true }]
|
||||
},
|
||||
{
|
||||
id: "settings",
|
||||
entries: [
|
||||
{ key: "settings.company", section: "settings", parent: "", plan_allowed: true },
|
||||
{ key: "settings.api_keys", section: "settings", parent: "", plan_allowed: true },
|
||||
{ key: "settings.team", section: "settings", parent: "", plan_allowed: true }
|
||||
{ key: "settings.api_keys", section: "settings", plan_allowed: true },
|
||||
{ key: "settings.team", section: "settings", plan_allowed: true }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "billing",
|
||||
entries: [{ key: "billing.checkout", section: "billing", plan_allowed: true }]
|
||||
},
|
||||
{
|
||||
id: "dashboard",
|
||||
entries: [{ key: "dashboard.stats", section: "dashboard", plan_allowed: true }]
|
||||
},
|
||||
{
|
||||
id: "support",
|
||||
entries: [{ key: "support.center", section: "support", parent: "", plan_allowed: true }]
|
||||
entries: [{ key: "support.center", section: "support", plan_allowed: true }]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
describe("rolePresetDenied", () => {
|
||||
it("full access denies nothing", () => {
|
||||
assert.deepEqual(rolePresetDenied(roleCatalog, roleById("full")), []);
|
||||
describe("integrator role", () => {
|
||||
it("is listed and presets catalog/feeds/processing/api keys", () => {
|
||||
const role = roleById("integrator");
|
||||
assert.ok(role);
|
||||
assert.deepEqual(role.sections, ["dashboard", "catalog", "feeds", "processing", "support"]);
|
||||
assert.deepEqual(role.allowKeys, ["settings.api_keys"]);
|
||||
|
||||
const denied = rolePresetDenied(sampleCatalog, role);
|
||||
assert.equal(denied.includes("settings.team"), true);
|
||||
assert.equal(denied.includes("billing.checkout"), true);
|
||||
assert.equal(denied.includes("catalog.products"), false);
|
||||
assert.equal(denied.includes("feeds.export_feeds"), false);
|
||||
assert.equal(denied.includes("processing.monitor"), false);
|
||||
assert.equal(denied.includes("settings.api_keys"), false);
|
||||
assert.equal(detectRole(sampleCatalog, denied), "integrator");
|
||||
});
|
||||
|
||||
it("manager keeps the operation but not money, team or API keys", () => {
|
||||
const denied = rolePresetDenied(roleCatalog, roleById("manager"));
|
||||
assert.deepEqual(denied, ["billing.checkout", "settings.api_keys", "settings.team"]);
|
||||
// Everything else in those sections stays open.
|
||||
assert.equal(isDenied(denied, "billing.overview"), false);
|
||||
assert.equal(isDenied(denied, "settings.company"), false);
|
||||
assert.equal(isDenied(denied, "marketing.campaigns"), false);
|
||||
});
|
||||
|
||||
it("catalog editor grants only dashboard, catalog and support", () => {
|
||||
const denied = rolePresetDenied(roleCatalog, roleById("catalog_editor"));
|
||||
assert.equal(isDenied(denied, "catalog.products"), false);
|
||||
assert.equal(isDenied(denied, "catalog.products.tab_error"), false);
|
||||
assert.equal(isDenied(denied, "support.center"), false);
|
||||
assert.equal(isDenied(denied, "feeds.list"), true);
|
||||
assert.equal(isDenied(denied, "marketing.campaigns"), true);
|
||||
assert.equal(isDenied(denied, "settings.api_keys"), true);
|
||||
// Denials collapse to roots — children are implied, never stored.
|
||||
assert.equal(denied.includes("catalog.products.tab_error"), false);
|
||||
});
|
||||
|
||||
it("marketing adds email on top of its sections", () => {
|
||||
const denied = rolePresetDenied(roleCatalog, roleById("marketing"));
|
||||
assert.equal(isDenied(denied, "marketing.campaigns"), false);
|
||||
assert.equal(isDenied(denied, "integrations.email"), false);
|
||||
assert.equal(isDenied(denied, "feeds.list"), true);
|
||||
});
|
||||
|
||||
it("viewer grants browsing only", () => {
|
||||
const denied = rolePresetDenied(roleCatalog, roleById("viewer"));
|
||||
assert.equal(isDenied(denied, "catalog.products"), false);
|
||||
assert.equal(isDenied(denied, "feeds.list"), false);
|
||||
assert.equal(isDenied(denied, "feeds.export_feeds"), true);
|
||||
assert.equal(isDenied(denied, "settings.company"), true);
|
||||
});
|
||||
|
||||
it("returns nothing for an unknown role", () => {
|
||||
assert.deepEqual(rolePresetDenied(roleCatalog, roleById("nope")), []);
|
||||
it("keeps role order with integrator after manager", () => {
|
||||
const ids = PERMISSION_ROLES.map((r) => r.id);
|
||||
assert.ok(ids.indexOf("manager") < ids.indexOf("integrator"));
|
||||
assert.ok(ids.includes("integrator"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("detectRole", () => {
|
||||
it("round-trips every preset", () => {
|
||||
for (const role of PERMISSION_ROLES) {
|
||||
const denied = rolePresetDenied(roleCatalog, role);
|
||||
assert.equal(detectRole(roleCatalog, denied), role.id, `role ${role.id}`);
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to custom for a hand-tuned selection", () => {
|
||||
const denied = setPermissionAllowed([], "catalog.categories", false, catalogKeys(roleCatalog));
|
||||
assert.equal(detectRole(roleCatalog, denied), CUSTOM_ROLE_ID);
|
||||
});
|
||||
|
||||
it("an empty selection is full access, not custom", () => {
|
||||
assert.equal(detectRole(roleCatalog, []), "full");
|
||||
describe("sectionPermissionTree", () => {
|
||||
it("nests fine-tune children under roots", () => {
|
||||
const tree = sectionPermissionTree(sampleCatalog.sections[0]);
|
||||
assert.equal(tree.length, 2);
|
||||
const products = tree.find((n) => n.root.key === "catalog.products");
|
||||
assert.ok(products);
|
||||
assert.deepEqual(
|
||||
products.children.map((c) => c.key),
|
||||
["catalog.products.tab_unprocessed"]
|
||||
);
|
||||
assert.deepEqual(childEntriesOf("catalog.categories", sampleCatalog.sections[0].entries), []);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -208,6 +208,13 @@ export const PERMISSION_ROLES: PermissionRole[] = [
|
||||
"billing.quick_upgrade"
|
||||
]
|
||||
},
|
||||
{
|
||||
// Third-party integrators: catalog + feeds/exports + processing + API keys.
|
||||
// Owners can fine-tune (disable any area) after picking this preset.
|
||||
id: "integrator",
|
||||
sections: ["dashboard", "catalog", "feeds", "processing", "support"],
|
||||
allowKeys: ["settings.api_keys"]
|
||||
},
|
||||
{ id: "catalog_editor", sections: ["dashboard", "catalog", "support"] },
|
||||
{ id: "feed_operator", sections: ["dashboard", "catalog", "feeds", "processing", "support"] },
|
||||
{
|
||||
@@ -242,6 +249,31 @@ function rootEntries(catalog: PermissionCatalog | null | undefined): PermissionC
|
||||
);
|
||||
}
|
||||
|
||||
/** Direct and nested grantable children under a root key. */
|
||||
export function childEntriesOf(
|
||||
parentKey: string,
|
||||
entries: PermissionCatalogEntry[]
|
||||
): PermissionCatalogEntry[] {
|
||||
const prefix = `${parentKey}.`;
|
||||
return entries.filter((entry) => entry.key.startsWith(prefix));
|
||||
}
|
||||
|
||||
/** Roots + nested children for a compact tree editor (fine-tune stays collapsed). */
|
||||
export type PermissionTreeRoot = {
|
||||
root: PermissionCatalogEntry;
|
||||
children: PermissionCatalogEntry[];
|
||||
};
|
||||
|
||||
export function sectionPermissionTree(
|
||||
section: PermissionCatalogSection
|
||||
): PermissionTreeRoot[] {
|
||||
const roots = section.entries.filter((entry) => !entry.parent);
|
||||
return roots.map((root) => ({
|
||||
root,
|
||||
children: childEntriesOf(root.key, section.entries)
|
||||
}));
|
||||
}
|
||||
|
||||
/** The denied list a role produces for this company's catalog. */
|
||||
export function rolePresetDenied(
|
||||
catalog: PermissionCatalog | null | undefined,
|
||||
|
||||
@@ -33,7 +33,8 @@
|
||||
formatJobErrorText,
|
||||
formatJobStatusLabel,
|
||||
formatJobStepLabel,
|
||||
formatProcessingTypeLabel
|
||||
formatProcessingTypeLabel,
|
||||
isActiveProcessingJob
|
||||
} from "$lib/job-status";
|
||||
|
||||
type JobGroup = {
|
||||
@@ -54,8 +55,12 @@
|
||||
let lastFetch = 0;
|
||||
let jobsAbort: AbortController | null = null;
|
||||
let jobsFetchGen = 0;
|
||||
const POLL_ACTIVE_MS = 5_000;
|
||||
const POLL_IDLE_MS = 20_000;
|
||||
const JOBS_LIMIT = 75;
|
||||
|
||||
const jobGroups = $derived(groupJobs(jobs));
|
||||
const hasActiveJobs = $derived(jobs.some((j) => isActiveProcessingJob(j.status)));
|
||||
|
||||
function isAbortError(err: unknown): boolean {
|
||||
return (
|
||||
@@ -91,9 +96,12 @@
|
||||
loading = true;
|
||||
if (!force) error = "";
|
||||
try {
|
||||
const payload = await api<ListResponse<ProcessingJob>>("/api/processing/jobs?limit=200", {
|
||||
const payload = await api<ListResponse<ProcessingJob>>(
|
||||
`/api/processing/jobs?limit=${JOBS_LIMIT}`,
|
||||
{
|
||||
signal: ac.signal
|
||||
});
|
||||
}
|
||||
);
|
||||
if (gen !== jobsFetchGen) return;
|
||||
jobs = unwrapList(payload);
|
||||
} catch (err) {
|
||||
@@ -121,10 +129,15 @@
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
if (timer) return;
|
||||
stopPolling();
|
||||
const ms = hasActiveJobs ? POLL_ACTIVE_MS : POLL_IDLE_MS;
|
||||
timer = setInterval(() => {
|
||||
void loadJobs();
|
||||
}, 5000);
|
||||
void loadJobs().then(() => {
|
||||
// Reschedule when activity changes so idle tenants are not hammered.
|
||||
const next = hasActiveJobs ? POLL_ACTIVE_MS : POLL_IDLE_MS;
|
||||
if (timer && next !== ms) startPolling();
|
||||
});
|
||||
}, ms);
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
|
||||
Reference in New Issue
Block a user