@parallelworks/ui 0.53.0 → 0.55.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +68 -9
  2. package/dist/access/AccessDrawer.d.ts +55 -0
  3. package/dist/access/AccessDrawer.js +445 -0
  4. package/dist/access/AccessList.d.ts +58 -0
  5. package/dist/access/AccessList.js +382 -0
  6. package/dist/access/GrantView.d.ts +35 -0
  7. package/dist/access/GrantView.js +188 -0
  8. package/dist/access/GroupPicker.d.ts +35 -0
  9. package/dist/access/GroupPicker.js +336 -0
  10. package/dist/access/focus.d.ts +10 -0
  11. package/dist/access/focus.js +27 -0
  12. package/dist/access/index.d.ts +3 -0
  13. package/dist/access/index.js +3 -0
  14. package/dist/access/model.d.ts +49 -0
  15. package/dist/access/model.js +129 -0
  16. package/dist/access/strings.d.ts +122 -0
  17. package/dist/access/strings.js +151 -0
  18. package/dist/access/types.d.ts +56 -0
  19. package/dist/ai/components/SlashMenu.js +1 -1
  20. package/dist/components/Combobox.d.ts +19 -0
  21. package/dist/components/Combobox.js +101 -50
  22. package/dist/components/DatePicker.js +4 -5
  23. package/dist/components/Drawer.d.ts +6 -1
  24. package/dist/components/Drawer.js +25 -16
  25. package/dist/components/Field.d.ts +8 -3
  26. package/dist/components/Field.js +32 -12
  27. package/dist/components/InfoTip.d.ts +3 -1
  28. package/dist/components/InfoTip.js +2 -1
  29. package/dist/components/Menu.js +4 -4
  30. package/dist/components/MenuEntries.js +1 -1
  31. package/dist/components/OptionDescription.d.ts +7 -0
  32. package/dist/components/OptionDescription.js +19 -0
  33. package/dist/components/Popover.d.ts +4 -1
  34. package/dist/components/Popover.js +2 -2
  35. package/dist/components/Provider.d.ts +7 -0
  36. package/dist/components/Provider.js +3 -0
  37. package/dist/components/Select.js +13 -11
  38. package/dist/components/comboboxItems.d.ts +10 -0
  39. package/dist/components/comboboxItems.js +8 -1
  40. package/dist/components/floating.d.ts +5 -2
  41. package/dist/components/floating.js +2 -2
  42. package/dist/editor/EditorField.js +1 -2
  43. package/dist/editor/Monaco.js +26 -2
  44. package/dist/editor/themes.d.ts +5 -0
  45. package/dist/editor/themes.js +18 -3
  46. package/dist/file-explorer/FileExplorer.js +2 -1
  47. package/dist/file-explorer/lib/paging.d.ts +1 -0
  48. package/dist/file-explorer/lib/paging.js +5 -1
  49. package/dist/index.d.ts +1 -1
  50. package/dist/index.js +2 -2
  51. package/dist/styles/theme.css +82 -62
  52. package/dist/styles.css +1 -1
  53. package/dist/theme/derive.d.ts +5 -3
  54. package/dist/theme/derive.js +74 -4
  55. package/package.json +6 -2
@@ -0,0 +1,336 @@
1
+ import { cx } from "../cx.js";
2
+ import { OrganizationIcon, RefreshIcon, UsersIcon } from "../icons.js";
3
+ import { useQueryMatch } from "../components/search.js";
4
+ import { Avatar } from "../components/Avatar.js";
5
+ import { Callout } from "../components/Callout.js";
6
+ import { Combobox } from "../components/Combobox.js";
7
+ import { Skeleton } from "../components/Skeleton.js";
8
+ import { useRelativeTime } from "../components/relativeTime.js";
9
+ import { StatusBadge } from "../components/StatusBadge.js";
10
+ import { covers } from "./model.js";
11
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
12
+ import { useEffect, useMemo, useState } from "react";
13
+ //#region src/access/GroupPicker.tsx
14
+ /** Waits this long on a highlighted group before asking for its details, so arrowing past
15
+ * groups doesn't ask for each one. */
16
+ var DESCRIBE_DELAY = 120;
17
+ /** Waits this long after the last keystroke before searching. */
18
+ var SEARCH_DELAY = 200;
19
+ /** Faces in a group's details, before the rest are a count. */
20
+ var FACES = 5;
21
+ /** Names in a group's details, before the rest are a count. */
22
+ var NAMES = 3;
23
+ function GroupTile({ everyone, className }) {
24
+ const Icon = everyone ? OrganizationIcon : UsersIcon;
25
+ return /* @__PURE__ */ jsx("span", {
26
+ "aria-hidden": "true",
27
+ className: cx("flex size-8 shrink-0 items-center justify-center rounded-md [&_svg]:size-4", everyone ? "bg-(--theme-info-muted) text-(--theme-info-text)" : "bg-(--theme-muted-panel-bg) text-(--theme-muted-text-color)", className),
28
+ children: /* @__PURE__ */ jsx(Icon, {})
29
+ });
30
+ }
31
+ /** The groups field: everyone pinned first, then recent groups, then all of them; a pane beside
32
+ * the list says who is in the highlighted group and what it holds here. */
33
+ function GroupPicker({ model, value, onValueChange, granting, resourceName, describeGroup, searchGroups, groupCount, recentGroupIds = [], cache, seen, id, describedBy, boundary, t }) {
34
+ const [highlighted, setHighlighted] = useState(void 0);
35
+ const details = useDetails(highlighted, describeGroup, cache);
36
+ const search = useSearch(searchGroups);
37
+ const match = useQueryMatch();
38
+ for (const group of [...model.groupById.values(), ...search.results ?? []]) seen.set(group.id, group);
39
+ const items = useMemo(() => {
40
+ const roleNames = (groupId) => (model.held.get(groupId) ?? []).map((r) => r.name);
41
+ const item = (group) => {
42
+ const everyone = group.kind === "everyone";
43
+ const held = roleNames(group.id);
44
+ const size = group.memberCount === void 0 ? void 0 : everyone ? t.people(group.memberCount) : t.members(group.memberCount);
45
+ return {
46
+ value: group.id,
47
+ label: everyone ? t.everyone : group.name,
48
+ description: [size, held.length > 0 && t.hasRoles(held)].filter(Boolean).join(" · "),
49
+ accessibleDescription: t.groupSummary(group.memberCount, everyone, cache.get(group.id)?.source, held),
50
+ icon: /* @__PURE__ */ jsx(GroupTile, {
51
+ everyone,
52
+ className: "size-7"
53
+ })
54
+ };
55
+ };
56
+ const everyone = model.everyone;
57
+ const recent = recentGroupIds.map((groupId) => seen.get(groupId)).filter((group) => group !== void 0 && group.kind !== "everyone");
58
+ const listed = search.results ?? [...model.groupById.values()];
59
+ const pinned = /* @__PURE__ */ new Set([everyone?.id, ...recent.map((group) => group.id)]);
60
+ const rest = listed.filter((group) => group.kind !== "everyone" && !pinned.has(group.id));
61
+ const searching = search.results !== void 0;
62
+ return [
63
+ ...everyone && (!searching || match(search.query, everyone.name, t.everyone)) ? [{
64
+ label: t.organization,
65
+ items: [item(everyone)]
66
+ }] : [],
67
+ ...!searching && recent.length > 0 ? [{
68
+ label: t.recent,
69
+ items: recent.map(item)
70
+ }] : [],
71
+ {
72
+ label: t.allGroups,
73
+ description: (groupCount ?? listed.length).toLocaleString(),
74
+ items: (searching ? listed.filter((g) => g.kind !== "everyone") : rest).map(item)
75
+ }
76
+ ].filter((group) => group.items.length > 0);
77
+ }, [
78
+ model,
79
+ recentGroupIds,
80
+ search.results,
81
+ search.query,
82
+ match,
83
+ groupCount,
84
+ seen,
85
+ cache,
86
+ t,
87
+ details.details
88
+ ]);
89
+ const everyoneChosen = model.everyone !== void 0 && value.includes(model.everyone.id);
90
+ const nameOf = (groupId) => {
91
+ const group = seen.get(groupId);
92
+ return group?.kind === "everyone" ? t.everyoneInOrganization : group?.name ?? groupId;
93
+ };
94
+ return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(Combobox, {
95
+ multiple: true,
96
+ id,
97
+ "aria-describedby": describedBy,
98
+ items,
99
+ value,
100
+ onValueChange,
101
+ itemToStringLabel: nameOf,
102
+ placeholder: t.addGroup,
103
+ onQueryChange: searchGroups ? search.setQuery : void 0,
104
+ loading: search.loading,
105
+ onItemHighlighted: setHighlighted,
106
+ collisionBoundary: boundary,
107
+ details: (groupId) => /* @__PURE__ */ jsx(GroupDetailsPane, {
108
+ group: groupId === void 0 ? void 0 : seen.get(groupId),
109
+ state: details,
110
+ model,
111
+ granting,
112
+ resourceName,
113
+ t
114
+ }),
115
+ detailsSummary: () => details.details ? /* @__PURE__ */ jsx(DetailsLine, {
116
+ details: details.details,
117
+ t
118
+ }) : null
119
+ }), /* @__PURE__ */ jsx("p", {
120
+ id: describedBy,
121
+ className: "m-0 mt-1.5 text-xs text-(--theme-muted-text-color)",
122
+ children: everyoneChosen ? t.everyoneCoversGroups : t.groupsHint(groupCount, model.everyone !== void 0)
123
+ })] });
124
+ }
125
+ /** The highlighted group's details: from the cache, or asked for once the highlight rests on
126
+ * it, and the request dropped when it moves on. */
127
+ function useDetails(groupId, describe, cache) {
128
+ const [failed, setFailed] = useState(void 0);
129
+ const [, setLoaded] = useState(0);
130
+ const cached = groupId === void 0 ? void 0 : cache.get(groupId);
131
+ useEffect(() => {
132
+ if (groupId === void 0 || !describe || cache.has(groupId)) return;
133
+ const controller = new AbortController();
134
+ const timer = setTimeout(() => {
135
+ describe(groupId, controller.signal).then((details) => {
136
+ cache.set(groupId, details);
137
+ setLoaded((n) => n + 1);
138
+ }, () => {
139
+ if (!controller.signal.aborted) setFailed(groupId);
140
+ });
141
+ }, DESCRIBE_DELAY);
142
+ return () => {
143
+ clearTimeout(timer);
144
+ controller.abort();
145
+ };
146
+ }, [
147
+ groupId,
148
+ describe,
149
+ cache
150
+ ]);
151
+ return {
152
+ groupId,
153
+ details: cached,
154
+ loading: groupId !== void 0 && describe !== void 0 && !cached && failed !== groupId,
155
+ failed: groupId !== void 0 && failed === groupId
156
+ };
157
+ }
158
+ /** A search on the host's side, run once typing pauses; a newer query drops the older one's. */
159
+ function useSearch(searchGroups) {
160
+ const [query, setQuery] = useState("");
161
+ const [found, setFound] = useState();
162
+ const trimmed = query.trim();
163
+ useEffect(() => {
164
+ if (!searchGroups || trimmed === "") return;
165
+ const controller = new AbortController();
166
+ const timer = setTimeout(() => {
167
+ searchGroups(trimmed, controller.signal).then((groups) => setFound({
168
+ query: trimmed,
169
+ groups
170
+ }), () => {
171
+ if (!controller.signal.aborted) setFound({
172
+ query: trimmed,
173
+ groups: []
174
+ });
175
+ });
176
+ }, SEARCH_DELAY);
177
+ return () => {
178
+ clearTimeout(timer);
179
+ controller.abort();
180
+ };
181
+ }, [searchGroups, trimmed]);
182
+ const active = searchGroups !== void 0 && trimmed !== "";
183
+ return {
184
+ query: trimmed,
185
+ setQuery,
186
+ results: active ? found?.groups ?? [] : void 0,
187
+ loading: active && found?.query !== trimmed
188
+ };
189
+ }
190
+ function PaneHeading({ children }) {
191
+ return /* @__PURE__ */ jsx("p", {
192
+ className: "m-0 mb-1.5 text-[11px] font-semibold tracking-wide text-(--theme-muted-text-color) uppercase",
193
+ children
194
+ });
195
+ }
196
+ function Faces({ details, t }) {
197
+ const shown = details.members.slice(0, FACES);
198
+ const more = details.memberCount - shown.length;
199
+ return /* @__PURE__ */ jsxs("div", {
200
+ className: "flex items-center gap-1",
201
+ children: [shown.map((member, index) => /* @__PURE__ */ jsx(Avatar, {
202
+ src: member.avatarUrl,
203
+ name: member.name,
204
+ size: "md"
205
+ }, `${index}:${member.name}`)), more > 0 && /* @__PURE__ */ jsx("span", {
206
+ className: "flex h-8 min-w-8 items-center justify-center rounded-full bg-(--theme-muted-panel-bg) px-1.5 text-xs font-medium",
207
+ children: t.moreMembers(more)
208
+ })]
209
+ });
210
+ }
211
+ var memberLine = (details, count, t) => {
212
+ const names = details.members.slice(0, count).map((member) => member.name);
213
+ return names.length > 0 ? t.memberNames(names, details.memberCount - names.length) : void 0;
214
+ };
215
+ /** The pane's one-line stand-in, under the highlighted group where the pane has no room. */
216
+ function Updated({ value, t }) {
217
+ const ago = useRelativeTime(value);
218
+ return /* @__PURE__ */ jsx("p", {
219
+ className: "m-0 mt-auto text-xs text-(--theme-muted-text-color)",
220
+ children: t.updated(ago)
221
+ });
222
+ }
223
+ function DetailsLine({ details, t }) {
224
+ return /* @__PURE__ */ jsx(Fragment, { children: [details.source === "synced" && t.syncedShort, memberLine(details, 2, t)].filter(Boolean).join(" · ") });
225
+ }
226
+ function GroupDetailsPane({ group, state, model, granting, resourceName, t }) {
227
+ if (!group) return /* @__PURE__ */ jsx("p", {
228
+ className: "m-0 p-4 text-xs text-(--theme-muted-text-color)",
229
+ children: t.noHighlight
230
+ });
231
+ const everyone = group.kind === "everyone";
232
+ const held = model.held.get(group.id) ?? [];
233
+ const details = state.groupId === group.id ? state.details : void 0;
234
+ const count = details?.memberCount ?? group.memberCount;
235
+ const heldIds = new Set(held.map((role) => role.id));
236
+ const fresh = granting.filter((role) => !heldIds.has(role.id));
237
+ const redundant = held.filter((role) => fresh.some((by) => covers(by, role)));
238
+ const source = everyone ? t.wholeOrganization : details?.source === "synced" ? t.synced : details?.source === "manual" ? t.manual : void 0;
239
+ return /* @__PURE__ */ jsxs("div", {
240
+ className: "flex min-h-full flex-col gap-4 p-4 text-sm",
241
+ children: [
242
+ /* @__PURE__ */ jsxs("div", {
243
+ className: "flex items-start gap-3",
244
+ children: [/* @__PURE__ */ jsx(GroupTile, { everyone }), /* @__PURE__ */ jsxs("div", {
245
+ className: "min-w-0",
246
+ children: [/* @__PURE__ */ jsx("p", {
247
+ className: "m-0 font-semibold wrap-break-word",
248
+ children: everyone ? t.everyone : group.name
249
+ }), state.loading && !everyone ? /* @__PURE__ */ jsx(Skeleton, {
250
+ width: "10rem",
251
+ className: "mt-1 text-xs"
252
+ }) : source && /* @__PURE__ */ jsxs("p", {
253
+ className: "m-0 flex items-start gap-1 text-xs text-(--theme-muted-text-color)",
254
+ children: [details?.source === "synced" && !everyone && /* @__PURE__ */ jsx(RefreshIcon, { className: "mt-0.5 size-3 shrink-0" }), source]
255
+ })]
256
+ })]
257
+ }),
258
+ state.loading ? /* @__PURE__ */ jsxs("div", {
259
+ className: "flex flex-col gap-2",
260
+ children: [
261
+ /* @__PURE__ */ jsx(Skeleton, {
262
+ count: 2,
263
+ className: "text-xs"
264
+ }),
265
+ /* @__PURE__ */ jsx(Skeleton, {
266
+ width: "60%",
267
+ className: "mt-2 text-xs"
268
+ }),
269
+ /* @__PURE__ */ jsx(Skeleton, {
270
+ circle: true,
271
+ height: 24,
272
+ width: "9rem",
273
+ className: "rounded-full"
274
+ })
275
+ ]
276
+ }) : /* @__PURE__ */ jsxs(Fragment, { children: [
277
+ state.failed && /* @__PURE__ */ jsx("p", {
278
+ className: "m-0 text-xs text-(--theme-error-text)",
279
+ children: t.detailsFailed
280
+ }),
281
+ details?.description && /* @__PURE__ */ jsx("p", {
282
+ className: "m-0 leading-snug",
283
+ children: details.description
284
+ }),
285
+ count !== void 0 && /* @__PURE__ */ jsxs("div", { children: [
286
+ /* @__PURE__ */ jsx(PaneHeading, { children: everyone ? t.people(count) : t.members(count) }),
287
+ details && details.members.length > 0 && /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(Faces, {
288
+ details,
289
+ t
290
+ }), /* @__PURE__ */ jsx("p", {
291
+ className: "m-0 mt-1.5 text-xs leading-snug text-(--theme-muted-text-color)",
292
+ children: memberLine(details, NAMES, t)
293
+ })] }),
294
+ everyone && /* @__PURE__ */ jsx("p", {
295
+ className: "m-0 mt-1.5 text-xs leading-snug text-(--theme-muted-text-color)",
296
+ children: t.joinLater
297
+ })
298
+ ] })
299
+ ] }),
300
+ everyone && fresh.length > 0 && /* @__PURE__ */ jsx(Callout, {
301
+ tone: "warning",
302
+ size: "sm",
303
+ icon: false,
304
+ children: t.everyoneReach(fresh.map((role) => role.name), count)
305
+ }),
306
+ /* @__PURE__ */ jsxs("div", { children: [
307
+ /* @__PURE__ */ jsx(PaneHeading, { children: t.onResource(resourceName) }),
308
+ held.length > 0 ? /* @__PURE__ */ jsx("div", {
309
+ className: "flex flex-wrap gap-1",
310
+ children: held.map((role) => /* @__PURE__ */ jsx(StatusBadge, {
311
+ size: "md",
312
+ variant: role.highImpact ? "warning" : "default",
313
+ children: role.name
314
+ }, role.id))
315
+ }) : /* @__PURE__ */ jsx("p", {
316
+ className: "m-0 text-xs text-(--theme-muted-text-color)",
317
+ children: t.noRolesHere
318
+ }),
319
+ redundant.length > 0 && /* @__PURE__ */ jsx("p", {
320
+ className: "m-0 mt-2 text-xs leading-snug text-(--theme-muted-text-color)",
321
+ children: t.becomesRedundant(fresh.filter((by) => redundant.some((role) => covers(by, role))).map((r) => r.name), redundant.map((role) => role.name))
322
+ }),
323
+ granting.length > 0 && fresh.length === 0 && /* @__PURE__ */ jsx("p", {
324
+ className: "m-0 mt-2 text-xs leading-snug text-(--theme-muted-text-color)",
325
+ children: t.alreadyHas(granting.map((role) => role.name))
326
+ })
327
+ ] }),
328
+ details?.updatedAt !== void 0 && details.updatedAt !== null && /* @__PURE__ */ jsx(Updated, {
329
+ value: details.updatedAt,
330
+ t
331
+ })
332
+ ]
333
+ });
334
+ }
335
+ //#endregion
336
+ export { GroupPicker, GroupTile };
@@ -0,0 +1,10 @@
1
+ type Find = () => HTMLElement | null | undefined;
2
+ /** Parks focus on the drawer itself before a change takes away the control that has it. When
3
+ * the focused control goes away, the drawer pulls focus back to itself, then again a frame
4
+ * later, which would undo wherever focus is sent next; parked, nothing is lost and it doesn't. */
5
+ export declare function holdFocus(): void;
6
+ /** Returns a function that sends focus to what `find` returns once the change it goes with
7
+ * has rendered. A save resolves on its own time, so the next commit is the moment to look,
8
+ * not the next frame. */
9
+ export declare function useFocusAfterRender(): (find: Find) => void;
10
+ export {};
@@ -0,0 +1,27 @@
1
+ import { useCallback, useEffect, useRef } from "react";
2
+ //#region src/access/focus.ts
3
+ /** Parks focus on the drawer itself before a change takes away the control that has it. When
4
+ * the focused control goes away, the drawer pulls focus back to itself, then again a frame
5
+ * later, which would undo wherever focus is sent next; parked, nothing is lost and it doesn't. */
6
+ function holdFocus() {
7
+ const active = document.activeElement;
8
+ const dialog = active instanceof HTMLElement ? active.closest("[role=\"dialog\"]") : null;
9
+ if (dialog && dialog !== active) dialog.focus({ preventScroll: true });
10
+ }
11
+ /** Returns a function that sends focus to what `find` returns once the change it goes with
12
+ * has rendered. A save resolves on its own time, so the next commit is the moment to look,
13
+ * not the next frame. */
14
+ function useFocusAfterRender() {
15
+ const pending = useRef(void 0);
16
+ useEffect(() => {
17
+ const find = pending.current;
18
+ pending.current = void 0;
19
+ find?.()?.focus();
20
+ });
21
+ return useCallback((find) => {
22
+ holdFocus();
23
+ pending.current = find;
24
+ }, []);
25
+ }
26
+ //#endregion
27
+ export { holdFocus, useFocusAfterRender };
@@ -0,0 +1,3 @@
1
+ export { type AccessArrangement, AccessDrawer, type AccessDrawerProps } from './AccessDrawer.js';
2
+ export { ACCESS_STRINGS, type AccessStrings, type AccessText, useAccessStrings, } from './strings.js';
3
+ export type { AccessChange, AccessGrant, AccessGroup, AccessGroupDetails, AccessMember, AccessPermission, AccessRole, } from './types.js';
@@ -0,0 +1,3 @@
1
+ import { ACCESS_STRINGS, useAccessStrings } from "./strings.js";
2
+ import { AccessDrawer } from "./AccessDrawer.js";
3
+ export { ACCESS_STRINGS, AccessDrawer, useAccessStrings };
@@ -0,0 +1,49 @@
1
+ import type { AccessChange, AccessGrant, AccessGroup, AccessPermission, AccessRole } from './types.js';
2
+ /** The grants indexed for the questions the drawer asks of them. */
3
+ export interface AccessModel {
4
+ roles: readonly AccessRole[];
5
+ roleById: ReadonlyMap<string, AccessRole>;
6
+ groupById: ReadonlyMap<string, AccessGroup>;
7
+ everyone: AccessGroup | undefined;
8
+ /** The roles each group holds itself, in the roles' order. */
9
+ held: ReadonlyMap<string, readonly AccessRole[]>;
10
+ /** The groups holding each role, in the order the host gave the groups. */
11
+ holders: ReadonlyMap<string, readonly AccessGroup[]>;
12
+ permissionLabel: (id: string) => string;
13
+ }
14
+ export declare function buildModel(roles: readonly AccessRole[], groups: readonly AccessGroup[], grants: readonly AccessGrant[], permissions?: readonly AccessPermission[]): AccessModel;
15
+ export declare const key: (grant: AccessGrant) => string;
16
+ /** Whether `by` gives every permission `role` does. A role with no permissions covers nothing
17
+ * and is covered by nothing, so two empty placeholders never read as duplicates. */
18
+ export declare function covers(by: AccessRole, role: AccessRole): boolean;
19
+ /** The roles a group's members have through everyone, which it doesn't hold itself. */
20
+ export declare function inheritedRoles(model: AccessModel, groupId: string): AccessRole[];
21
+ /** Why a role a group holds is redundant: its other roles that already give everything this one
22
+ * does, and whether everyone has it already. */
23
+ export interface Overlap {
24
+ coveredBy: AccessRole[];
25
+ everyone: boolean;
26
+ }
27
+ export declare function overlapOf(model: AccessModel, groupId: string, role: AccessRole): Overlap;
28
+ /** What taking roles from a group does to its members. */
29
+ export interface Removal {
30
+ /** The permissions they no longer have from any role, in the order the roles list them. */
31
+ lost: string[];
32
+ /** Roles still giving them some of what the removed roles gave: their own others, then
33
+ * everyone's. */
34
+ keep: AccessRole[];
35
+ keepThroughEveryone: AccessRole[];
36
+ }
37
+ /** What the group loses: the removed roles' permissions that no role it keeps, its own or
38
+ * everyone's, still gives. */
39
+ export declare function removalOf(model: AccessModel, groupId: string, roleIds: readonly string[]): Removal;
40
+ /** Granting each role to each group: the grants that add something, and the pairs held already. */
41
+ export declare function grantPlan(model: AccessModel, groupIds: readonly string[], roleIds: readonly string[]): {
42
+ grant: AccessGrant[];
43
+ held: AccessGrant[];
44
+ };
45
+ /** The change that takes `change` back. */
46
+ export declare function invert(change: AccessChange): AccessChange;
47
+ /** Warns, in a development build, about props that would make the drawer wrong in a way that
48
+ * is easy to miss: a duplicate id shows one of two, and an unknown id shows nothing. */
49
+ export declare function warnAboutMisuse(roles: readonly AccessRole[], groups: readonly AccessGroup[], grants: readonly AccessGrant[], permissions: readonly AccessPermission[] | undefined): void;
@@ -0,0 +1,129 @@
1
+ import { isDev } from "../dev.js";
2
+ //#region src/access/model.ts
3
+ function buildModel(roles, groups, grants, permissions = []) {
4
+ const roleById = new Map(roles.map((role) => [role.id, role]));
5
+ const groupById = new Map(groups.map((group) => [group.id, group]));
6
+ const granted = new Set(grants.map(key));
7
+ const held = /* @__PURE__ */ new Map();
8
+ const holders = /* @__PURE__ */ new Map();
9
+ for (const group of groups) {
10
+ const own = roles.filter((role) => granted.has(key({
11
+ groupId: group.id,
12
+ roleId: role.id
13
+ })));
14
+ if (own.length > 0) held.set(group.id, own);
15
+ for (const role of own) holders.set(role.id, [...holders.get(role.id) ?? [], group]);
16
+ }
17
+ const labels = new Map(permissions.map((p) => [p.id, p.label]));
18
+ return {
19
+ roles,
20
+ roleById,
21
+ groupById,
22
+ everyone: groups.find((group) => group.kind === "everyone"),
23
+ held,
24
+ holders,
25
+ permissionLabel: (id) => labels.get(id) ?? id
26
+ };
27
+ }
28
+ var key = (grant) => `${grant.groupId}\u0000${grant.roleId}`;
29
+ var permissionsOf = (roles) => new Set(roles.flatMap((role) => role.permissions));
30
+ /** Whether `by` gives every permission `role` does. A role with no permissions covers nothing
31
+ * and is covered by nothing, so two empty placeholders never read as duplicates. */
32
+ function covers(by, role) {
33
+ if (by.id === role.id || role.permissions.length === 0) return false;
34
+ const given = new Set(by.permissions);
35
+ return role.permissions.every((p) => given.has(p));
36
+ }
37
+ var isEveryone = (model, groupId) => model.everyone?.id === groupId;
38
+ /** The roles a group's members have through everyone, which it doesn't hold itself. */
39
+ function inheritedRoles(model, groupId) {
40
+ if (!model.everyone || isEveryone(model, groupId)) return [];
41
+ const own = new Set((model.held.get(groupId) ?? []).map((role) => role.id));
42
+ return (model.held.get(model.everyone.id) ?? []).filter((role) => !own.has(role.id));
43
+ }
44
+ function overlapOf(model, groupId, role) {
45
+ const own = (model.held.get(groupId) ?? []).filter((other) => other.id !== role.id);
46
+ const everyoneRoles = model.everyone && !isEveryone(model, groupId) ? model.held.get(model.everyone.id) ?? [] : [];
47
+ return {
48
+ coveredBy: own.filter((other) => covers(other, role)),
49
+ everyone: everyoneRoles.some((other) => other.id === role.id || covers(other, role))
50
+ };
51
+ }
52
+ /** What the group loses: the removed roles' permissions that no role it keeps, its own or
53
+ * everyone's, still gives. */
54
+ function removalOf(model, groupId, roleIds) {
55
+ const removing = new Set(roleIds);
56
+ const removed = (model.held.get(groupId) ?? []).filter((role) => removing.has(role.id));
57
+ const keep = (model.held.get(groupId) ?? []).filter((role) => !removing.has(role.id));
58
+ const throughEveryone = inheritedRoles(model, groupId);
59
+ const still = permissionsOf([...keep, ...throughEveryone]);
60
+ const taken = permissionsOf(removed);
61
+ const lost = [...taken].filter((p) => !still.has(p));
62
+ const share = new Map([...keep, ...throughEveryone].map((role) => [role.id, new Set(role.permissions.filter((p) => taken.has(p)))]));
63
+ const worth = (role) => {
64
+ const mine = share.get(role.id) ?? /* @__PURE__ */ new Set();
65
+ return mine.size > 0 && ![...share].some(([id, theirs]) => id !== role.id && [...mine].every((p) => theirs.has(p)) && (theirs.size > mine.size || id < role.id));
66
+ };
67
+ return {
68
+ lost,
69
+ keep: keep.filter(worth),
70
+ keepThroughEveryone: throughEveryone.filter(worth)
71
+ };
72
+ }
73
+ /** Granting each role to each group: the grants that add something, and the pairs held already. */
74
+ function grantPlan(model, groupIds, roleIds) {
75
+ const grant = [];
76
+ const held = [];
77
+ for (const groupId of groupIds) {
78
+ const own = new Set((model.held.get(groupId) ?? []).map((role) => role.id));
79
+ for (const roleId of roleIds) (own.has(roleId) ? held : grant).push({
80
+ groupId,
81
+ roleId
82
+ });
83
+ }
84
+ return {
85
+ grant,
86
+ held
87
+ };
88
+ }
89
+ /** The change that takes `change` back. */
90
+ function invert(change) {
91
+ return {
92
+ grant: change.revoke,
93
+ revoke: change.grant
94
+ };
95
+ }
96
+ /** Warns, in a development build, about props that would make the drawer wrong in a way that
97
+ * is easy to miss: a duplicate id shows one of two, and an unknown id shows nothing. */
98
+ function warnAboutMisuse(roles, groups, grants, permissions) {
99
+ if (!isDev()) return;
100
+ const warn = (message) => console.warn(`AccessDrawer: ${message}`);
101
+ for (const id of duplicates(roles.map((role) => role.id))) warn(`two roles share the id "${id}"; ids must be unique.`);
102
+ for (const id of duplicates(groups.map((group) => group.id))) warn(`two groups share the id "${id}"; ids must be unique.`);
103
+ if (groups.filter((group) => group.kind === "everyone").length > 1) warn("more than one group is everyone; only the first counts.");
104
+ const roleIds = new Set(roles.map((role) => role.id));
105
+ const groupIds = new Set(groups.map((group) => group.id));
106
+ for (const grant of grants) {
107
+ if (!roleIds.has(grant.roleId)) warn(`a grant names the unknown role "${grant.roleId}"; it isn't shown.`);
108
+ if (!groupIds.has(grant.groupId)) warn(`a grant names the unknown group "${grant.groupId}"; pass every granted group.`);
109
+ }
110
+ for (const k of duplicates(grants.map(key))) {
111
+ const [groupId, roleId] = k.split("\0");
112
+ warn(`the grant of "${roleId}" to "${groupId}" is listed twice.`);
113
+ }
114
+ if (permissions) {
115
+ const known = new Set(permissions.map((p) => p.id));
116
+ for (const role of roles) for (const p of role.permissions.filter((id) => !known.has(id))) warn(`the role "${role.id}" names the unknown permission "${p}"; it reads as its id.`);
117
+ }
118
+ }
119
+ function duplicates(ids) {
120
+ const seen = /* @__PURE__ */ new Set();
121
+ const repeated = /* @__PURE__ */ new Set();
122
+ for (const id of ids) {
123
+ if (seen.has(id)) repeated.add(id);
124
+ seen.add(id);
125
+ }
126
+ return [...repeated];
127
+ }
128
+ //#endregion
129
+ export { buildModel, covers, grantPlan, inheritedRoles, invert, key, overlapOf, removalOf, warnAboutMisuse };