major fixes

This commit is contained in:
2026-08-22 18:51:17 +02:00
parent 0ff24b1534
commit 0c154254c3
36 changed files with 2212 additions and 42 deletions
+172
View File
@@ -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;
}