This commit is contained in:
2026-08-22 19:23:15 +02:00
parent 0c154254c3
commit bd762d1ccf
21 changed files with 1047 additions and 111 deletions
+111 -4
View File
@@ -91,7 +91,7 @@ export function setPermissionAllowed(
if (!allowed) {
for (const descendant of descendantsOf(key, allKeys)) next.delete(descendant);
next.add(key);
return normalize(next, allKeys);
return normalizeDenied(next, allKeys);
}
next.delete(key);
@@ -104,11 +104,11 @@ export function setPermissionAllowed(
if (!openPath.has(sibling)) next.add(sibling);
}
}
return normalize(next, allKeys);
return normalizeDenied(next, allKeys);
}
/** Drop keys outside the catalog and denials already implied by a denied ancestor. */
function normalize(denied: Set<string>, allKeys: string[]): string[] {
export function normalizeDenied(denied: Set<string>, allKeys: string[]): string[] {
const known = new Set(allKeys);
const out: string[] = [];
for (const key of denied) {
@@ -122,7 +122,7 @@ function normalize(denied: Set<string>, allKeys: string[]): string[] {
/** Deny every grantable key (the always-on shell keys are not in the catalog). */
export function denyAll(catalog: PermissionCatalog | null | undefined): string[] {
const all = catalogKeys(catalog);
return normalize(new Set(all), all);
return normalizeDenied(new Set(all), all);
}
/** Allowed / total counts for the summary line, ignoring keys the plan already denies. */
@@ -170,3 +170,110 @@ export function filterCatalog(
}
return out;
}
/**
* Role presets.
*
* Raw feature keys ("catalog.products.tab_error") are precise but unreadable to most
* owners, so the editor leads with a role and derives the checkbox state from it.
* A role grants whole dashboard sections, with a few key-level exceptions; anything
* not granted is denied, and parent-prefix denial collapses the stored list.
*
* Roles describe which AREAS a teammate can open — the model is page-level, not
* verb-level, so a role never implies "read-only" within an area it grants.
*/
export type PermissionRole = {
id: string;
/** Sections fully granted, or "all" for every section. */
sections: "all" | string[];
/** Extra keys granted outside the granted sections. */
allowKeys?: string[];
/** Keys denied even though their section is granted. */
denyKeys?: string[];
};
export const PERMISSION_ROLES: PermissionRole[] = [
{ id: "full", sections: "all" },
{
// Runs the whole product operation, but money and account access stay with the owner.
id: "manager",
sections: "all",
denyKeys: [
"settings.api_keys",
"settings.team",
"settings.team_invite",
"billing.checkout",
"billing.customer_portal",
"billing.quick_upgrade"
]
},
{ id: "catalog_editor", sections: ["dashboard", "catalog", "support"] },
{ id: "feed_operator", sections: ["dashboard", "catalog", "feeds", "processing", "support"] },
{
id: "marketing",
sections: ["dashboard", "catalog", "marketing", "support"],
allowKeys: ["integrations.email"]
},
{
id: "viewer",
sections: ["dashboard", "support"],
allowKeys: [
"catalog.products",
"catalog.categories",
"catalog.attributes",
"catalog.standard_fields",
"feeds.list"
]
}
];
/** Sentinel returned by detectRole when the selection matches no preset. */
export const CUSTOM_ROLE_ID = "custom";
export function roleById(id: string): PermissionRole | null {
return PERMISSION_ROLES.find((role) => role.id === id) ?? null;
}
/** Top-level grantable entries (those with no grantable ancestor in the catalog). */
function rootEntries(catalog: PermissionCatalog | null | undefined): PermissionCatalogEntry[] {
return (catalog?.sections ?? []).flatMap((section) =>
section.entries.filter((entry) => !entry.parent)
);
}
/** The denied list a role produces for this company's catalog. */
export function rolePresetDenied(
catalog: PermissionCatalog | null | undefined,
role: PermissionRole | null
): string[] {
if (!role) return [];
const all = catalogKeys(catalog);
const allowKeys = new Set(role.allowKeys ?? []);
const denyKeys = new Set(role.denyKeys ?? []);
const denied = new Set<string>();
for (const entry of rootEntries(catalog)) {
const sectionGranted =
role.sections === "all" || role.sections.includes(entry.section);
if (denyKeys.has(entry.key) || (!sectionGranted && !allowKeys.has(entry.key))) {
denied.add(entry.key);
}
}
// Key-level exceptions below a granted root (e.g. deny settings.api_keys under settings).
for (const key of denyKeys) {
if (!isDenied(denied, key)) denied.add(key);
}
return normalizeDenied(denied, all);
}
/** Which role the current selection corresponds to, or CUSTOM_ROLE_ID. */
export function detectRole(
catalog: PermissionCatalog | null | undefined,
denied: string[]
): string {
for (const role of PERMISSION_ROLES) {
if (samePermissions(rolePresetDenied(catalog, role), denied)) return role.id;
}
return CUSTOM_ROLE_ID;
}