Files
descrybe/apps/web/src/lib/member-permissions.ts
T

280 lines
9.0 KiB
TypeScript
Raw Normal View History

2026-08-22 18:51:17 +02:00
/**
* 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);
2026-08-22 19:23:15 +02:00
return normalizeDenied(next, allKeys);
2026-08-22 18:51:17 +02:00
}
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);
}
}
2026-08-22 19:23:15 +02:00
return normalizeDenied(next, allKeys);
2026-08-22 18:51:17 +02:00
}
/** Drop keys outside the catalog and denials already implied by a denied ancestor. */
2026-08-22 19:23:15 +02:00
export function normalizeDenied(denied: Set<string>, allKeys: string[]): string[] {
2026-08-22 18:51:17 +02:00
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);
2026-08-22 19:23:15 +02:00
return normalizeDenied(new Set(all), all);
2026-08-22 18:51:17 +02:00
}
/** 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;
}
2026-08-22 19:23:15 +02:00
/**
* 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;
}