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 {
|
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>
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
+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 { 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");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
Reference in New Issue
Block a user