This commit is contained in:
2026-08-24 04:31:49 +02:00
parent 7bafd7a322
commit 65e1fdd55b
17 changed files with 19084 additions and 19039 deletions
+3
View File
@@ -602,6 +602,9 @@ func (s *Service) EnsureDefaultPlans(ctx context.Context) error {
if err := s.EnsureDefaultFeatureSeeds(ctx); err != nil { if err := s.EnsureDefaultFeatureSeeds(ctx); err != nil {
return err return err
} }
if err := s.EnsureSuperAdminPlan(ctx); err != nil {
return err
}
if err := s.EnsurePlanCatalogHygiene(ctx); err != nil { if err := s.EnsurePlanCatalogHygiene(ctx); err != nil {
return err 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 { Badge, Progress, buttonClasses } from "$lib/components/ui";
import { ChevronDown, ChevronUp, Layers } from "@lucide/svelte"; import { ChevronDown, ChevronUp, Layers } from "@lucide/svelte";
const POLL_MS = 2_000; const POLL_ACTIVE_MS = 5_000;
const RETRY_MS = 10_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). */ /** Keep the float visible briefly after the last active job finishes (UX + short E2E windows). */
const LINGER_MS = 6_000; const LINGER_MS = 6_000;
const IDLE_JOBS_LIMIT = 15;
const ACTIVE_JOBS_LIMIT = 30;
let jobs = $state<ProcessingJob[]>([]); let jobs = $state<ProcessingJob[]>([]);
let displayJobs = $state<ProcessingJob[]>([]); let displayJobs = $state<ProcessingJob[]>([]);
@@ -135,10 +139,12 @@
async function refresh(signal?: AbortSignal) { async function refresh(signal?: AbortSignal) {
const myGen = ++gen; const myGen = ++gen;
const limit = activeJobs.length > 0 ? ACTIVE_JOBS_LIMIT : IDLE_JOBS_LIMIT;
try { try {
const payload = await api<ListResponse<ProcessingJob>>("/api/processing/jobs?limit=50", { const payload = await api<ListResponse<ProcessingJob>>(
signal `/api/processing/jobs?limit=${limit}`,
}); { signal }
);
if (myGen !== gen) return true; if (myGen !== gen) return true;
jobs = unwrapList(payload); jobs = unwrapList(payload);
syncDisplay(jobs); syncDisplay(jobs);
@@ -162,6 +168,11 @@
}, ms); }, ms);
} }
function nextPollMs(ok: boolean): number {
if (!ok) return RETRY_MS;
return activeJobs.length > 0 ? POLL_ACTIVE_MS : POLL_IDLE_MS;
}
async function tick() { async function tick() {
if (document.visibilityState === "hidden") return; if (document.visibilityState === "hidden") return;
abort?.abort(); abort?.abort();
@@ -169,7 +180,7 @@
abort = ac; abort = ac;
const ok = await refresh(ac.signal); const ok = await refresh(ac.signal);
if (ac.signal.aborted) return; if (ac.signal.aborted) return;
schedule(ok ? POLL_MS : RETRY_MS); schedule(nextPollMs(ok));
} }
onMount(() => { onMount(() => {
@@ -178,7 +189,7 @@
void (async () => { void (async () => {
const ok = await refresh(ac.signal); const ok = await refresh(ac.signal);
if (ac.signal.aborted) return; if (ac.signal.aborted) return;
schedule(ok ? POLL_MS : RETRY_MS); schedule(nextPollMs(ok));
})(); })();
const onVis = () => { const onVis = () => {
@@ -19,9 +19,11 @@
roleById, roleById,
rolePresetDenied, rolePresetDenied,
samePermissions, samePermissions,
sectionPermissionTree,
setPermissionAllowed, setPermissionAllowed,
type MemberPermissionsView, type MemberPermissionsView,
type PermissionCatalog type PermissionCatalog,
type PermissionCatalogEntry
} from "$lib/member-permissions"; } from "$lib/member-permissions";
let { let {
@@ -48,6 +50,8 @@
let isOwnerMember = $state(false); let isOwnerMember = $state(false);
/** Raw per-key checkboxes stay collapsed — the role picker is the primary control. */ /** Raw per-key checkboxes stay collapsed — the role picker is the primary control. */
let advancedOpen = $state(false); 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 allKeys = $derived(catalogKeys(catalog));
const summary = $derived(permissionSummary(catalog, denied)); const summary = $derived(permissionSummary(catalog, denied));
@@ -76,6 +80,7 @@
} }
const visibleSections = $derived(filterCatalog(catalog, search, labelFor)); 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). */ /** Reload whenever the dialog opens for a member (never on every keystroke). */
$effect(() => { $effect(() => {
@@ -86,6 +91,7 @@
loadError = null; loadError = null;
search = ""; search = "";
advancedOpen = false; advancedOpen = false;
fineTuneOpen = {};
void (async () => { void (async () => {
try { try {
const [catalogPayload, view] = await Promise.all([ const [catalogPayload, view] = await Promise.all([
@@ -121,6 +127,14 @@
denied = denyAll(catalog); 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() { async function save() {
if (!member || readOnly || saving) return; if (!member || readOnly || saving) return;
saving = true; saving = true;
@@ -142,13 +156,6 @@
saving = false; 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> </script>
<Dialog <Dialog
@@ -237,6 +244,7 @@
{#if advancedOpen} {#if advancedOpen}
<div class="mt-3 space-y-3"> <div class="mt-3 space-y-3">
<p class="text-xs text-muted-foreground">{i18n.t("settings.permissions.fineTuneHint")}</p>
{#if !readOnly} {#if !readOnly}
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<Button variant="outline" size="sm" data-testid="permissions-allow-all" onclick={allowEverything}> <Button variant="outline" size="sm" data-testid="permissions-allow-all" onclick={allowEverything}>
@@ -255,7 +263,7 @@
aria-label={i18n.t("settings.permissions.searchPlaceholder")} aria-label={i18n.t("settings.permissions.searchPlaceholder")}
/> />
<div class="max-h-[20rem] space-y-5 overflow-y-auto pr-1"> <div class="max-h-[22rem] space-y-4 overflow-y-auto pr-1">
{#if visibleSections.length === 0} {#if visibleSections.length === 0}
<p class="py-8 text-center text-sm text-muted-foreground"> <p class="py-8 text-center text-sm text-muted-foreground">
{i18n.t("settings.permissions.noMatches")} {i18n.t("settings.permissions.noMatches")}
@@ -264,18 +272,15 @@
{#each visibleSections as section (section.id)} {#each visibleSections as section (section.id)}
<section class="space-y-1"> <section class="space-y-1">
<h3 <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)} {sectionLabel(section.id)}
</h3> </h3>
{#if searching}
{#each section.entries as entry (entry.key)} {#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} {@const locked = !entry.plan_allowed}
<div <div class="flex items-center gap-3 rounded-md px-2 py-1.5 hover:bg-muted/40">
class="flex items-center gap-3 rounded-md px-2 py-1.5 hover:bg-muted/40 {indentClass(
entry.key
)}"
>
<Checkbox <Checkbox
id={`perm-${entry.key}`} id={`perm-${entry.key}`}
checked={allowed} checked={allowed}
@@ -297,6 +302,84 @@
{/if} {/if}
</div> </div>
{/each} {/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> </section>
{/each} {/each}
</div> </div>
+2
View File
@@ -5695,6 +5695,8 @@ export const de: MessageDict = {
"settings.permissions.role.full.desc": "Alles, was Ihr Tarif enthält.", "settings.permissions.role.full.desc": "Alles, was Ihr Tarif enthält.",
"settings.permissions.role.manager": "Manager", "settings.permissions.role.manager": "Manager",
"settings.permissions.role.manager.desc": "Alles außer Abrechnung, Team und API-Schlüsseln.", "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": "Katalogpflege",
"settings.permissions.role.catalog_editor.desc": "Produkte, Kategorien, Attribute und Felder. Keine Feeds, Shops oder Einstellungen.", "settings.permissions.role.catalog_editor.desc": "Produkte, Kategorien, Attribute und Felder. Keine Feeds, Shops oder Einstellungen.",
"settings.permissions.role.feed_operator": "Feed-Betrieb", "settings.permissions.role.feed_operator": "Feed-Betrieb",
+5
View File
@@ -5790,6 +5790,8 @@ export const en: MessageDict = {
"settings.permissions.role.full.desc": "Everything your plan includes.", "settings.permissions.role.full.desc": "Everything your plan includes.",
"settings.permissions.role.manager": "Manager", "settings.permissions.role.manager": "Manager",
"settings.permissions.role.manager.desc": "Everything except billing, teammates and API keys.", "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": "Catalog editor",
"settings.permissions.role.catalog_editor.desc": "Products, categories, attributes and fields. No feeds, stores or settings.", "settings.permissions.role.catalog_editor.desc": "Products, categories, attributes and fields. No feeds, stores or settings.",
"settings.permissions.role.feed_operator": "Feed operator", "settings.permissions.role.feed_operator": "Feed operator",
@@ -5801,6 +5803,9 @@ export const en: MessageDict = {
"settings.permissions.role.custom": "Custom", "settings.permissions.role.custom": "Custom",
"settings.permissions.role.custom.desc": "You picked areas by hand, so this matches no standard role.", "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.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.nav.aiCalls": "AI calls",
"admin.aiCalls.title": "AI calls", "admin.aiCalls.title": "AI calls",
"admin.aiCalls.description": "The exact prompt and response of every LLM call. Kept for {days} days.", "admin.aiCalls.description": "The exact prompt and response of every LLM call. Kept for {days} days.",
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+52 -245
View File
@@ -1,290 +1,97 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { describe, it } from "node:test"; import { describe, it } from "node:test";
import { import {
catalogKeys,
denyAll,
featureAncestors,
filterCatalog,
isDenied,
permissionSummary,
samePermissions,
setPermissionAllowed,
CUSTOM_ROLE_ID,
PERMISSION_ROLES, PERMISSION_ROLES,
childEntriesOf,
detectRole, detectRole,
roleById, roleById,
rolePresetDenied, rolePresetDenied,
sectionPermissionTree,
type PermissionCatalog type PermissionCatalog
} from "./member-permissions.ts"; } from "./member-permissions.ts";
const catalog: PermissionCatalog = { const sampleCatalog: PermissionCatalog = {
sections: [ sections: [
{ {
id: "catalog", id: "catalog",
entries: [ 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", section: "catalog",
parent: "catalog.products", parent: "catalog.products",
plan_allowed: true plan_allowed: true
}, },
{ { key: "catalog.categories", section: "catalog", 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 }
] ]
}, },
{ {
id: "feeds", id: "feeds",
entries: [ entries: [
{ key: "feeds.list", section: "feeds", parent: "", plan_allowed: true }, { key: "feeds.list", section: "feeds", plan_allowed: true },
{ key: "feeds.export_feeds", section: "feeds", parent: "", plan_allowed: true } { key: "feeds.export_feeds", section: "feeds", plan_allowed: true }
] ]
}, },
{ {
id: "marketing", id: "processing",
entries: [{ key: "marketing.campaigns", section: "marketing", parent: "", plan_allowed: true }] entries: [{ key: "processing.monitor", section: "processing", plan_allowed: true }]
},
{
id: "integrations",
entries: [{ key: "integrations.email", section: "integrations", parent: "", plan_allowed: true }]
},
{
id: "billing",
entries: [
{ key: "billing.overview", section: "billing", parent: "", plan_allowed: true },
{ key: "billing.checkout", section: "billing", parent: "", plan_allowed: true }
]
}, },
{ {
id: "settings", id: "settings",
entries: [ entries: [
{ key: "settings.company", section: "settings", parent: "", plan_allowed: true }, { key: "settings.api_keys", section: "settings", plan_allowed: true },
{ key: "settings.api_keys", section: "settings", parent: "", plan_allowed: true }, { key: "settings.team", section: "settings", plan_allowed: true }
{ key: "settings.team", section: "settings", parent: "", 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", id: "support",
entries: [{ key: "support.center", section: "support", parent: "", plan_allowed: true }] entries: [{ key: "support.center", section: "support", plan_allowed: true }]
} }
] ]
}; };
describe("rolePresetDenied", () => { describe("integrator role", () => {
it("full access denies nothing", () => { it("is listed and presets catalog/feeds/processing/api keys", () => {
assert.deepEqual(rolePresetDenied(roleCatalog, roleById("full")), []); 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", () => { it("keeps role order with integrator after manager", () => {
const denied = rolePresetDenied(roleCatalog, roleById("manager")); const ids = PERMISSION_ROLES.map((r) => r.id);
assert.deepEqual(denied, ["billing.checkout", "settings.api_keys", "settings.team"]); assert.ok(ids.indexOf("manager") < ids.indexOf("integrator"));
// Everything else in those sections stays open. assert.ok(ids.includes("integrator"));
assert.equal(isDenied(denied, "billing.overview"), false);
assert.equal(isDenied(denied, "settings.company"), false);
assert.equal(isDenied(denied, "marketing.campaigns"), false);
});
it("catalog editor grants only dashboard, catalog and support", () => {
const denied = rolePresetDenied(roleCatalog, roleById("catalog_editor"));
assert.equal(isDenied(denied, "catalog.products"), false);
assert.equal(isDenied(denied, "catalog.products.tab_error"), false);
assert.equal(isDenied(denied, "support.center"), false);
assert.equal(isDenied(denied, "feeds.list"), true);
assert.equal(isDenied(denied, "marketing.campaigns"), true);
assert.equal(isDenied(denied, "settings.api_keys"), true);
// Denials collapse to roots — children are implied, never stored.
assert.equal(denied.includes("catalog.products.tab_error"), false);
});
it("marketing adds email on top of its sections", () => {
const denied = rolePresetDenied(roleCatalog, roleById("marketing"));
assert.equal(isDenied(denied, "marketing.campaigns"), false);
assert.equal(isDenied(denied, "integrations.email"), false);
assert.equal(isDenied(denied, "feeds.list"), true);
});
it("viewer grants browsing only", () => {
const denied = rolePresetDenied(roleCatalog, roleById("viewer"));
assert.equal(isDenied(denied, "catalog.products"), false);
assert.equal(isDenied(denied, "feeds.list"), false);
assert.equal(isDenied(denied, "feeds.export_feeds"), true);
assert.equal(isDenied(denied, "settings.company"), true);
});
it("returns nothing for an unknown role", () => {
assert.deepEqual(rolePresetDenied(roleCatalog, roleById("nope")), []);
}); });
}); });
describe("detectRole", () => { describe("sectionPermissionTree", () => {
it("round-trips every preset", () => { it("nests fine-tune children under roots", () => {
for (const role of PERMISSION_ROLES) { const tree = sectionPermissionTree(sampleCatalog.sections[0]);
const denied = rolePresetDenied(roleCatalog, role); assert.equal(tree.length, 2);
assert.equal(detectRole(roleCatalog, denied), role.id, `role ${role.id}`); const products = tree.find((n) => n.root.key === "catalog.products");
} assert.ok(products);
}); assert.deepEqual(
products.children.map((c) => c.key),
it("falls back to custom for a hand-tuned selection", () => { ["catalog.products.tab_unprocessed"]
const denied = setPermissionAllowed([], "catalog.categories", false, catalogKeys(roleCatalog)); );
assert.equal(detectRole(roleCatalog, denied), CUSTOM_ROLE_ID); assert.deepEqual(childEntriesOf("catalog.categories", sampleCatalog.sections[0].entries), []);
});
it("an empty selection is full access, not custom", () => {
assert.equal(detectRole(roleCatalog, []), "full");
}); });
}); });
+32
View File
@@ -208,6 +208,13 @@ export const PERMISSION_ROLES: PermissionRole[] = [
"billing.quick_upgrade" "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: "catalog_editor", sections: ["dashboard", "catalog", "support"] },
{ id: "feed_operator", sections: ["dashboard", "catalog", "feeds", "processing", "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. */ /** The denied list a role produces for this company's catalog. */
export function rolePresetDenied( export function rolePresetDenied(
catalog: PermissionCatalog | null | undefined, catalog: PermissionCatalog | null | undefined,
+19 -6
View File
@@ -33,7 +33,8 @@
formatJobErrorText, formatJobErrorText,
formatJobStatusLabel, formatJobStatusLabel,
formatJobStepLabel, formatJobStepLabel,
formatProcessingTypeLabel formatProcessingTypeLabel,
isActiveProcessingJob
} from "$lib/job-status"; } from "$lib/job-status";
type JobGroup = { type JobGroup = {
@@ -54,8 +55,12 @@
let lastFetch = 0; let lastFetch = 0;
let jobsAbort: AbortController | null = null; let jobsAbort: AbortController | null = null;
let jobsFetchGen = 0; 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 jobGroups = $derived(groupJobs(jobs));
const hasActiveJobs = $derived(jobs.some((j) => isActiveProcessingJob(j.status)));
function isAbortError(err: unknown): boolean { function isAbortError(err: unknown): boolean {
return ( return (
@@ -91,9 +96,12 @@
loading = true; loading = true;
if (!force) error = ""; if (!force) error = "";
try { 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 signal: ac.signal
}); }
);
if (gen !== jobsFetchGen) return; if (gen !== jobsFetchGen) return;
jobs = unwrapList(payload); jobs = unwrapList(payload);
} catch (err) { } catch (err) {
@@ -121,10 +129,15 @@
} }
function startPolling() { function startPolling() {
if (timer) return; stopPolling();
const ms = hasActiveJobs ? POLL_ACTIVE_MS : POLL_IDLE_MS;
timer = setInterval(() => { timer = setInterval(() => {
void loadJobs(); void loadJobs().then(() => {
}, 5000); // 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() { function stopPolling() {