major fixes
This commit is contained in:
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* 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 normalize(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 normalize(next, allKeys);
|
||||
}
|
||||
|
||||
/** Drop keys outside the catalog and denials already implied by a denied ancestor. */
|
||||
function normalize(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 normalize(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;
|
||||
}
|
||||
Reference in New Issue
Block a user