280 lines
9.0 KiB
TypeScript
280 lines
9.0 KiB
TypeScript
/**
|
|
* Per-member access permissions (settings > team).
|
|
*
|
|
* The company owner stores a sparse list of DENIED feature keys per member; everything
|
|
* else is inherited (allowed, still bounded by the plan). Denying a parent key denies
|
|
* its descendants, mirroring billing.MemberDeniesFeature in
|
|
* apps/api/internal/billing/member_permissions.go.
|
|
*
|
|
* Kept free of $lib/i18n and $app imports so node:test can exercise it directly —
|
|
* labels come from $lib/plan-feature-catalog at render time.
|
|
*/
|
|
|
|
/** One grantable feature key as returned by GET /api/team/permission-catalog. */
|
|
export type PermissionCatalogEntry = {
|
|
key: string;
|
|
section: string;
|
|
/** Nearest grantable ancestor key, or "" for a top-level area. */
|
|
parent?: string;
|
|
/** False when the company's own plan already denies the key (nothing to grant). */
|
|
plan_allowed: boolean;
|
|
};
|
|
|
|
export type PermissionCatalogSection = {
|
|
id: string;
|
|
entries: PermissionCatalogEntry[];
|
|
};
|
|
|
|
export type PermissionCatalog = {
|
|
sections: PermissionCatalogSection[];
|
|
};
|
|
|
|
/** GET/PUT /api/team/{userID}/permissions. */
|
|
export type MemberPermissionsView = {
|
|
user_id: string;
|
|
role: string;
|
|
is_owner: boolean;
|
|
denied: string[];
|
|
restricted: boolean;
|
|
};
|
|
|
|
/** Ancestor keys of "a.b.c" → ["a", "a.b"]. */
|
|
export function featureAncestors(key: string): string[] {
|
|
const parts = key.split(".");
|
|
const out: string[] = [];
|
|
for (let i = 1; i < parts.length; i++) out.push(parts.slice(0, i).join("."));
|
|
return out;
|
|
}
|
|
|
|
/** True when key, or any ancestor of it, is in the denied set. */
|
|
export function isDenied(denied: Iterable<string>, key: string): boolean {
|
|
const set = denied instanceof Set ? denied : new Set(denied);
|
|
if (set.size === 0) return false;
|
|
if (set.has(key)) return true;
|
|
return featureAncestors(key).some((parent) => set.has(parent));
|
|
}
|
|
|
|
/** Every catalog key, flattened in section order. */
|
|
export function catalogKeys(catalog: PermissionCatalog | null | undefined): string[] {
|
|
if (!catalog?.sections) return [];
|
|
return catalog.sections.flatMap((section) => section.entries.map((entry) => entry.key));
|
|
}
|
|
|
|
function descendantsOf(key: string, allKeys: string[]): string[] {
|
|
return allKeys.filter((candidate) => candidate.startsWith(`${key}.`));
|
|
}
|
|
|
|
/** Direct children of key within allKeys (no grandchildren). */
|
|
function childrenOf(key: string, allKeys: string[]): string[] {
|
|
const depth = key.split(".").length;
|
|
return allKeys.filter(
|
|
(candidate) => candidate.startsWith(`${key}.`) && candidate.split(".").length === depth + 1
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Toggle one key and return the normalized denied list.
|
|
*
|
|
* Normalization keeps the stored overlay minimal and unambiguous:
|
|
* - denying a key drops its now-redundant descendants,
|
|
* - allowing a key whose ancestor is denied lifts that ancestor and pushes the denial
|
|
* down onto the ancestor's other branches, so no other area silently re-opens.
|
|
*/
|
|
export function setPermissionAllowed(
|
|
denied: Iterable<string>,
|
|
key: string,
|
|
allowed: boolean,
|
|
allKeys: string[]
|
|
): string[] {
|
|
const next = new Set(denied);
|
|
|
|
if (!allowed) {
|
|
for (const descendant of descendantsOf(key, allKeys)) next.delete(descendant);
|
|
next.add(key);
|
|
return normalizeDenied(next, allKeys);
|
|
}
|
|
|
|
next.delete(key);
|
|
// Lift every denied ancestor, re-denying its other branches so only `key` opens up.
|
|
for (const ancestor of featureAncestors(key)) {
|
|
if (!next.has(ancestor)) continue;
|
|
next.delete(ancestor);
|
|
const openPath = new Set([key, ...featureAncestors(key)]);
|
|
for (const sibling of childrenOf(ancestor, allKeys)) {
|
|
if (!openPath.has(sibling)) next.add(sibling);
|
|
}
|
|
}
|
|
return normalizeDenied(next, allKeys);
|
|
}
|
|
|
|
/** Drop keys outside the catalog and denials already implied by a denied ancestor. */
|
|
export function normalizeDenied(denied: Set<string>, allKeys: string[]): string[] {
|
|
const known = new Set(allKeys);
|
|
const out: string[] = [];
|
|
for (const key of denied) {
|
|
if (!known.has(key)) continue;
|
|
if (featureAncestors(key).some((parent) => denied.has(parent))) continue;
|
|
out.push(key);
|
|
}
|
|
return out.sort();
|
|
}
|
|
|
|
/** Deny every grantable key (the always-on shell keys are not in the catalog). */
|
|
export function denyAll(catalog: PermissionCatalog | null | undefined): string[] {
|
|
const all = catalogKeys(catalog);
|
|
return normalizeDenied(new Set(all), all);
|
|
}
|
|
|
|
/** Allowed / total counts for the summary line, ignoring keys the plan already denies. */
|
|
export function permissionSummary(
|
|
catalog: PermissionCatalog | null | undefined,
|
|
denied: Iterable<string>
|
|
): { allowed: number; total: number } {
|
|
const set = new Set(denied);
|
|
let allowed = 0;
|
|
let total = 0;
|
|
for (const section of catalog?.sections ?? []) {
|
|
for (const entry of section.entries) {
|
|
if (!entry.plan_allowed) continue;
|
|
total += 1;
|
|
if (!isDenied(set, entry.key)) allowed += 1;
|
|
}
|
|
}
|
|
return { allowed, total };
|
|
}
|
|
|
|
/** True when two denied lists describe the same overlay (order-insensitive). */
|
|
export function samePermissions(a: string[], b: string[]): boolean {
|
|
if (a.length !== b.length) return false;
|
|
const set = new Set(a);
|
|
return b.every((key) => set.has(key));
|
|
}
|
|
|
|
/** Filter a catalog to entries matching a free-text query over key and label. */
|
|
export function filterCatalog(
|
|
catalog: PermissionCatalog | null | undefined,
|
|
query: string,
|
|
labelFor: (key: string) => string
|
|
): PermissionCatalogSection[] {
|
|
const needle = query.trim().toLowerCase();
|
|
const sections = catalog?.sections ?? [];
|
|
if (!needle) return sections;
|
|
const out: PermissionCatalogSection[] = [];
|
|
for (const section of sections) {
|
|
const entries = section.entries.filter(
|
|
(entry) =>
|
|
entry.key.toLowerCase().includes(needle) ||
|
|
labelFor(entry.key).toLowerCase().includes(needle)
|
|
);
|
|
if (entries.length > 0) out.push({ ...section, entries });
|
|
}
|
|
return out;
|
|
}
|
|
|
|
|
|
/**
|
|
* 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;
|
|
}
|