@parallelworks/ui 0.53.0 → 0.54.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 (44) hide show
  1. package/README.md +55 -5
  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/Drawer.d.ts +6 -1
  23. package/dist/components/Drawer.js +25 -16
  24. package/dist/components/Menu.js +4 -4
  25. package/dist/components/MenuEntries.js +1 -1
  26. package/dist/components/OptionDescription.d.ts +7 -0
  27. package/dist/components/OptionDescription.js +19 -0
  28. package/dist/components/Provider.d.ts +7 -0
  29. package/dist/components/Provider.js +3 -0
  30. package/dist/components/Select.js +13 -11
  31. package/dist/components/comboboxItems.d.ts +10 -0
  32. package/dist/components/comboboxItems.js +8 -1
  33. package/dist/components/floating.d.ts +5 -2
  34. package/dist/components/floating.js +2 -2
  35. package/dist/editor/themes.d.ts +5 -0
  36. package/dist/editor/themes.js +17 -2
  37. package/dist/file-explorer/FileExplorer.js +2 -1
  38. package/dist/file-explorer/lib/paging.d.ts +1 -0
  39. package/dist/file-explorer/lib/paging.js +5 -1
  40. package/dist/styles/theme.css +82 -62
  41. package/dist/styles.css +1 -1
  42. package/dist/theme/derive.d.ts +5 -3
  43. package/dist/theme/derive.js +61 -3
  44. package/package.json +5 -1
package/README.md CHANGED
@@ -14,6 +14,7 @@ so an application ships only what it imports:
14
14
  | `./editor` | Monaco editor (`monaco-editor` is an optional peer), loaded on first mount with the languages it needs; YAML validation, completion and hover run in a bundled worker |
15
15
  | `./logviewer` | log viewer with ANSI color and search |
16
16
  | `./file-explorer` | object storage explorer over a host-supplied provider |
17
+ | `./access` | `AccessDrawer`: who has access to a resource, and granting roles to groups |
17
18
  | `./ai`, `./ai/openai`, `./ai/mock` | chat interface and adapters |
18
19
  | `./theme.css`, `./base.css`, `./fonts.css`, `./styles.css`, `./logviewer.css`, `./ai.css` | stylesheets |
19
20
 
@@ -63,7 +64,7 @@ Each need has one component. Reach for it before styling markup by hand.
63
64
  | One of a few choices, all shown | `RadioGroup` in a form; `SegmentedControl` in a toolbar, or as a field's control in a `Field group` |
64
65
  | Narrowing a list by values, such as statuses: several at once, or one or none | `ToggleGroup` |
65
66
  | Narrowing what is shown by typing | `SearchInput`; `ListSearchControl` on a list page's toolbar |
66
- | A value from a list | `Select`, or `Combobox` when the list needs filtering |
67
+ | A value from a list | `Select`, or `Combobox` when the list needs filtering; its `details` pane describes the highlighted item, and `onQueryChange` hands the search to the host |
67
68
  | Free text with suggestions, or tags | `Autocomplete`; `Combobox` with `multiple creatable` |
68
69
  | A range | `Slider` |
69
70
  | A page's breadcrumbs, title and commands | `PageHeader`, shown by the shell's `PageHeaderBar` (see [Page header](#page-header)) |
@@ -76,7 +77,8 @@ Each need has one component. Reach for it before styling markup by hand.
76
77
  | A hint for a control | `Tooltip`; a `Field`'s `help` for more about a field, which its control reads and which opens as Tab reaches it; `InfoTip` for help about anything else |
77
78
  | A preview of what a link leads to | `HoverCard` |
78
79
  | A task that blocks the page | `Modal`, laid out with `title`, `description` and `footer` as a `Drawer` is; `ConfirmModal` to confirm one action; `CreateModal` to name and create one thing |
79
- | A panel beside the page | `Drawer` |
80
+ | A panel beside the page | `Drawer`; `onBack` for a view it opens over its first, such as a form |
81
+ | Who has access to something, and changing it | `AccessDrawer` from `./access` (see [Access](#access)) |
80
82
  | A message set into the page: guidance, a warning, an error | `Callout`; `variant="bar"` across a surface under its header |
81
83
  | A passing notice, or the progress of work in the background | `toast` |
82
84
  | How full something is, how far a task has come | `Meter`, `ProgressBar` |
@@ -313,11 +315,13 @@ In React, `useColorMode(preference)` follows the device while the preference is
313
315
  `system`, and `useApplyTheme(theme, mode)` applies the theme to the document
314
316
  root (or to an element you pass) and sets `color-scheme`. `applyTheme` sets the
315
317
  display-p3 values on a wide-gamut screen. To render a theme on the server,
316
- `themeStylesheet(selector, scheme)` returns a stylesheet with both blocks.
318
+ `themeStylesheet(selector, scheme)` returns a stylesheet with both blocks; given
319
+ `{ light, dark }` schemes, it pairs each token's two values with `light-dark()`.
317
320
  `deriveTheme(scheme)` returns the sRGB tokens alone.
318
321
 
319
- `theme.css` defines the default theme's tokens, so a page renders before any
320
- script runs:
322
+ `theme.css` defines the default theme's tokens for both modes, each as
323
+ `light-dark()`, so a page renders before any script runs and a token the host
324
+ leaves unset follows the `color-scheme` it sets:
321
325
 
322
326
  ```css
323
327
  /* In a Tailwind v4 build: */
@@ -470,6 +474,52 @@ five seconds, eight for a warning or an error, pausing while the pointer or focu
470
474
  is on it; `timeout: false` keeps it. `loading` stays until an update settles it
471
475
  into a tone. Pass an `id` to replace a toast rather than stack another.
472
476
 
477
+ ## Access
478
+
479
+ `AccessDrawer` shows who holds which role on one resource, and grants and takes
480
+ them away. A role is a named set of permissions; groups are granted roles, never
481
+ permissions one by one. Two roles may overlap: the drawer works the overlap out
482
+ from each role's `permissions` and says so as a hint ("Sudo covers this too"),
483
+ and before a role is taken away it lists exactly what the group loses, the
484
+ permissions no role it keeps still gives, and what it keeps.
485
+
486
+ ```tsx
487
+ import { AccessDrawer, type AccessChange } from '@parallelworks/ui/access'
488
+
489
+ <AccessDrawer
490
+ open={open}
491
+ onClose={close}
492
+ resourceName={cluster.name}
493
+ roles={[{ id: 'login', name: 'Login', description: 'Sign in.', permissions: ['cluster:login'] }]}
494
+ permissions={[{ id: 'cluster:login', label: 'Sign in' }]}
495
+ groups={[{ id: 'org', name: 'Everyone', kind: 'everyone', memberCount: 214 }, ...granted]}
496
+ grants={grants} // { groupId, roleId }[]
497
+ onChange={(change: AccessChange) => api.patch(change)} // { grant, revoke }
498
+ describeGroup={(id, signal) => api.group(id, { signal })}
499
+ searchGroups={(query, signal) => api.groups({ query, signal })}
500
+ readOnly={!canManage}
501
+ readOnlyReason="Only people with the Admin role can change it."
502
+ />
503
+ ```
504
+
505
+ - `onChange` gets one change at a time, holding only grants that change
506
+ something. Return a promise: its rows show they are saving until it settles,
507
+ and when it rejects the drawer says that nothing changed and offers Retry. A
508
+ saved change offers Undo in a toast. Pass the new grants back through
509
+ `grants`; the drawer never keeps its own copy.
510
+ - `groups` holds every group that has a role here, and everyone if the resource
511
+ can be granted to everyone (`kind: 'everyone'`, at most one). Without
512
+ `searchGroups` it is also the list to grant to; with it, the picker asks the
513
+ host as the reader types.
514
+ - `describeGroup` fills the pane beside the group picker for the highlighted
515
+ group: where it comes from, its first members and when it last changed. It is
516
+ asked once per group while the drawer is mounted, and aborted when the
517
+ highlight moves on.
518
+ - `highImpact` roles carry a tag, and granting anything to everyone says how
519
+ many people it reaches and asks to confirm.
520
+ - A development build warns about duplicate ids and grants that name unknown
521
+ roles or groups.
522
+
473
523
  ## Host chrome
474
524
 
475
525
  Popups, dialogs and drawers portal to `<body>` and read the tokens from the
@@ -0,0 +1,55 @@
1
+ import { type ReactNode } from 'react';
2
+ import { type AccessModel } from './model.js';
3
+ import { type AccessStrings } from './strings.js';
4
+ import type { AccessChange, AccessGrant, AccessGroup, AccessGroupDetails, AccessPermission, AccessRole } from './types.js';
5
+ export type AccessArrangement = 'role' | 'group';
6
+ export interface AccessDrawerProps {
7
+ open: boolean;
8
+ onClose: () => void;
9
+ /** What access is to, as the reader knows it: "gpu-cluster-east". */
10
+ resourceName: string;
11
+ /** Every role that can be granted here, in the order to list them. */
12
+ roles: readonly AccessRole[];
13
+ /** Every group that holds a role here, and everyone when the resource can be granted to
14
+ * everyone. Without `searchGroups`, also every group that can be granted one. */
15
+ groups: readonly AccessGroup[];
16
+ /** Who holds which role. The drawer shows these and never changes them itself. */
17
+ grants: readonly AccessGrant[];
18
+ /** Names the permissions roles list, for what a group loses when it loses a role. */
19
+ permissions?: readonly AccessPermission[] | undefined;
20
+ /** Saves a change. Return a promise that settles once it is saved: until then its rows show
21
+ * that they are saving, and when it rejects the drawer says that nothing changed and offers
22
+ * to try again. Pass the new grants back through `grants`. */
23
+ onChange?: ((change: AccessChange) => void | Promise<unknown>) | undefined;
24
+ /** The grants are still loading: skeletons hold their place. */
25
+ loading?: boolean | undefined;
26
+ /** Shows who has access without letting the reader change it. */
27
+ readOnly?: boolean | undefined;
28
+ /** Who can change it, said in read-only mode: "Only people with the Admin role can change it." */
29
+ readOnlyReason?: ReactNode;
30
+ /** More about a group, asked for when it is highlighted in the group picker and kept while the
31
+ * drawer is mounted. A highlight that moves on aborts `signal`. */
32
+ describeGroup?: ((groupId: string, signal: AbortSignal) => Promise<AccessGroupDetails>) | undefined;
33
+ /** Finds groups to grant to, for an organization with too many to pass in `groups`. A newer
34
+ * query aborts `signal`. Without it, the picker filters `groups`. */
35
+ searchGroups?: ((query: string, signal: AbortSignal) => Promise<readonly AccessGroup[]>) | undefined;
36
+ /** How many groups the organization has, for the picker's hint and heading. */
37
+ groupCount?: number | undefined;
38
+ /** Groups the reader granted to lately, listed first in the picker. */
39
+ recentGroupIds?: readonly string[] | undefined;
40
+ /** How the list starts out: role by role, or group by group. */
41
+ defaultArrangement?: AccessArrangement | undefined;
42
+ /** As `Drawer`'s. */
43
+ width?: number | string | undefined;
44
+ }
45
+ /** Who has access to one resource, and the place to change it: roles granted to groups.
46
+ * Overlapping roles are allowed and only redundant, which the drawer points out; taking a
47
+ * role away first says exactly what the group loses. Each change is saved as it is made, with
48
+ * Undo in a toast. */
49
+ export declare function AccessDrawer({ open, onClose, resourceName, roles, groups, grants, permissions, onChange, loading, readOnly, readOnlyReason, describeGroup, searchGroups, groupCount, recentGroupIds, defaultArrangement, width, }: AccessDrawerProps): import("react").JSX.Element;
50
+ type Match = (query: string, ...texts: (string | undefined)[]) => boolean;
51
+ export declare function filterAccess(model: AccessModel, arrangement: AccessArrangement, query: string, match: Match, t: AccessStrings): {
52
+ shown: Map<string, readonly string[]>;
53
+ order: string[];
54
+ };
55
+ export {};
@@ -0,0 +1,445 @@
1
+ import { AddIcon, LockIcon, UsersIcon } from "../icons.js";
2
+ import { toast } from "../components/Toast.js";
3
+ import { EmptyState } from "../components/EmptyState.js";
4
+ import { useQueryMatch } from "../components/search.js";
5
+ import { Button } from "../components/Button.js";
6
+ import { Callout } from "../components/Callout.js";
7
+ import { Drawer } from "../components/Drawer.js";
8
+ import { LoadingRegion, Skeleton } from "../components/Skeleton.js";
9
+ import { SearchInput } from "../components/SearchInput.js";
10
+ import { SegmentedControl } from "../components/SegmentedControl.js";
11
+ import { buildModel, invert, key, warnAboutMisuse } from "./model.js";
12
+ import { ByGroup, ByRole, removeKey } from "./AccessList.js";
13
+ import { holdFocus, useFocusAfterRender } from "./focus.js";
14
+ import { useGrantView } from "./GrantView.js";
15
+ import { useAccessStrings } from "./strings.js";
16
+ import { jsx, jsxs } from "react/jsx-runtime";
17
+ import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
18
+ //#region src/access/AccessDrawer.tsx
19
+ var EMPTY_SELECTION = {
20
+ groupIds: [],
21
+ roleIds: []
22
+ };
23
+ /** Who has access to one resource, and the place to change it: roles granted to groups.
24
+ * Overlapping roles are allowed and only redundant, which the drawer points out; taking a
25
+ * role away first says exactly what the group loses. Each change is saved as it is made, with
26
+ * Undo in a toast. */
27
+ function AccessDrawer({ open, onClose, resourceName, roles, groups, grants, permissions, onChange, loading = false, readOnly = false, readOnlyReason, describeGroup, searchGroups, groupCount, recentGroupIds, defaultArrangement = "role", width = 560 }) {
28
+ const t = useAccessStrings();
29
+ const uid = useId();
30
+ const model = useMemo(() => buildModel(roles, groups, grants, permissions), [
31
+ roles,
32
+ groups,
33
+ grants,
34
+ permissions
35
+ ]);
36
+ useEffect(() => {
37
+ if (!loading) warnAboutMisuse(roles, groups, grants, permissions);
38
+ }, [
39
+ roles,
40
+ groups,
41
+ grants,
42
+ permissions,
43
+ loading
44
+ ]);
45
+ const [view, setView] = useState("list");
46
+ const [selection, setSelection] = useState(EMPTY_SELECTION);
47
+ const [arrangement, setArrangement] = useState(defaultArrangement);
48
+ const [query, setQuery] = useState("");
49
+ const [expanded, setExpanded] = useState({
50
+ role: /* @__PURE__ */ new Set(),
51
+ group: /* @__PURE__ */ new Set()
52
+ });
53
+ const [removal, setRemoval] = useState();
54
+ const [saving, setSaving] = useState();
55
+ const [failure, setFailure] = useState();
56
+ const cache = useRef(/* @__PURE__ */ new Map()).current;
57
+ const seen = useRef(/* @__PURE__ */ new Map()).current;
58
+ const body = useRef(null);
59
+ const [bodyElement, setBodyElement] = useState(null);
60
+ const bodyRef = useCallback((element) => {
61
+ body.current = element;
62
+ setBodyElement(element);
63
+ }, []);
64
+ const focusLater = useFocusAfterRender();
65
+ const change = useRef(onChange);
66
+ change.current = onChange;
67
+ const match = useQueryMatch();
68
+ const filtered = useFiltered(model, arrangement, query.trim(), match, t);
69
+ const editable = !readOnly && onChange !== void 0;
70
+ const sectionId = (id) => `${uid}${arrangement}${id}`;
71
+ const names = (changed) => {
72
+ const all = [...changed.grant, ...changed.revoke];
73
+ return {
74
+ groupNames: [...new Set(all.map((grant) => grant.groupId))].map((id) => {
75
+ const group = seen.get(id) ?? model.groupById.get(id);
76
+ return group?.kind === "everyone" ? t.everyone : group?.name ?? id;
77
+ }),
78
+ roleNames: [...new Set(all.map((grant) => grant.roleId))].map((id) => model.roleById.get(id)?.name ?? id)
79
+ };
80
+ };
81
+ const undo = (done) => {
82
+ Promise.resolve(change.current?.(invert(done))).then(() => toast.info(t.undone), (error) => toast.error(t.nothingChanged(messageOf(error)), { title: t.undoFailed }));
83
+ };
84
+ /** Saves a change and reports how it went; resolves true once saved. */
85
+ const save = async (next, where) => {
86
+ if (!onChange || next.grant.length === 0 && next.revoke.length === 0) return false;
87
+ const { groupNames, roleNames } = names(next);
88
+ setSaving(next);
89
+ setFailure(void 0);
90
+ try {
91
+ await onChange(next);
92
+ } catch (error) {
93
+ setFailure({
94
+ change: next,
95
+ title: next.grant.length > 0 ? t.grantFailed(groupNames, roleNames) : t.removeFailed(groupNames[0] ?? "", roleNames),
96
+ message: messageOf(error),
97
+ ...where
98
+ });
99
+ return false;
100
+ } finally {
101
+ setSaving(void 0);
102
+ }
103
+ toast.success(next.grant.length > 0 ? t.granted(groupNames, roleNames) : t.removed(groupNames, roleNames), { action: {
104
+ label: t.common.undo,
105
+ onClick: () => undo(next)
106
+ } });
107
+ return true;
108
+ };
109
+ const toList = () => {
110
+ setView("list");
111
+ setFailure((current) => current?.inGrant ? void 0 : current);
112
+ focusLater(() => visible(body.current?.querySelectorAll("[data-grant-access]")));
113
+ };
114
+ const toGrant = (groupIds = []) => {
115
+ setSelection({
116
+ groupIds,
117
+ roleIds: []
118
+ });
119
+ setRemoval(void 0);
120
+ setView("grant");
121
+ focusLater(() => body.current?.querySelector("input[role=\"combobox\"]"));
122
+ };
123
+ const grant = async (next) => {
124
+ if (await save(next, {
125
+ inGrant: true,
126
+ groupId: next.grant[0]?.groupId ?? ""
127
+ })) {
128
+ setExpanded((current) => ({
129
+ role: /* @__PURE__ */ new Set([...current.role, ...next.grant.map((g) => g.roleId)]),
130
+ group: /* @__PURE__ */ new Set([...current.group, ...next.grant.map((g) => g.groupId)])
131
+ }));
132
+ toList();
133
+ }
134
+ };
135
+ const remove = async (pending) => {
136
+ const next = {
137
+ grant: [],
138
+ revoke: pending.roleIds.map((roleId) => ({
139
+ groupId: pending.groupId,
140
+ roleId
141
+ }))
142
+ };
143
+ const anchor = arrangement === "role" && !pending.all ? pending.roleIds[0] : void 0;
144
+ await save(next, {
145
+ inGrant: false,
146
+ groupId: pending.groupId,
147
+ roleId: anchor
148
+ });
149
+ setRemoval(void 0);
150
+ focusLater(() => document.getElementById(sectionId(anchor ?? pending.groupId)) ?? body.current?.querySelector("input[type=\"search\"]"));
151
+ };
152
+ const retry = (failed) => failed.inGrant ? grant(failed.change) : save(failed.change, {
153
+ inGrant: false,
154
+ groupId: failed.groupId,
155
+ roleId: failed.roleId
156
+ });
157
+ const failureNode = failure && /* @__PURE__ */ jsx(Callout, {
158
+ tone: "error",
159
+ live: true,
160
+ title: failure.title,
161
+ action: /* @__PURE__ */ jsx(Button, {
162
+ size: "sm",
163
+ variant: "secondary",
164
+ onClick: () => retry(failure),
165
+ children: t.common.retry
166
+ }),
167
+ onDismiss: () => setFailure(void 0),
168
+ children: t.nothingChanged(failure.message)
169
+ });
170
+ const close = () => {
171
+ setView("list");
172
+ setRemoval(void 0);
173
+ setQuery("");
174
+ onClose();
175
+ };
176
+ const grantView = useGrantView({
177
+ model,
178
+ selection,
179
+ onSelectionChange: setSelection,
180
+ resourceName,
181
+ describeGroup,
182
+ searchGroups,
183
+ groupCount,
184
+ recentGroupIds,
185
+ cache,
186
+ seen,
187
+ saving: saving !== void 0,
188
+ failure: failure?.inGrant ? failureNode : void 0,
189
+ onGrant: grant,
190
+ onCancel: toList,
191
+ focusLater,
192
+ boundary: bodyElement,
193
+ t
194
+ });
195
+ const savingKeys = useMemo(() => new Set(saving?.revoke.map(key) ?? []), [saving]);
196
+ const empty = !loading && model.held.size === 0;
197
+ const View = arrangement === "role" ? ByRole : ByGroup;
198
+ const grantButton = (className) => /* @__PURE__ */ jsx(Button, {
199
+ variant: "primary",
200
+ icon: /* @__PURE__ */ jsx(AddIcon, {}),
201
+ "data-grant-access": "",
202
+ disabled: loading,
203
+ onClick: () => toGrant(),
204
+ className,
205
+ children: t.grantAccess
206
+ });
207
+ const list = /* @__PURE__ */ jsxs("div", {
208
+ className: "@container/access flex min-h-0 flex-1 flex-col",
209
+ children: [
210
+ readOnly && /* @__PURE__ */ jsx("div", {
211
+ className: "px-4 pt-3",
212
+ children: /* @__PURE__ */ jsxs(Callout, {
213
+ tone: "info",
214
+ icon: /* @__PURE__ */ jsx(LockIcon, {}),
215
+ children: [
216
+ t.readOnly,
217
+ " ",
218
+ readOnlyReason ?? t.readOnlyReason
219
+ ]
220
+ })
221
+ }),
222
+ !empty && /* @__PURE__ */ jsxs("div", {
223
+ className: "flex flex-wrap items-center gap-2 border-b border-(--theme-border) px-4 py-3",
224
+ children: [
225
+ /* @__PURE__ */ jsx("div", {
226
+ className: "w-full @min-[30rem]/access:w-auto",
227
+ children: /* @__PURE__ */ jsx(SegmentedControl, {
228
+ fill: true,
229
+ label: t.arrange,
230
+ value: arrangement,
231
+ onValueChange: setArrangement,
232
+ options: [{
233
+ value: "role",
234
+ label: t.byRole
235
+ }, {
236
+ value: "group",
237
+ label: t.byGroup
238
+ }]
239
+ })
240
+ }),
241
+ /* @__PURE__ */ jsx(SearchInput, {
242
+ label: t.filter,
243
+ value: query,
244
+ onValueChange: (next) => {
245
+ setQuery(next);
246
+ setExpanded(expandedFor(model, next.trim(), match, t));
247
+ },
248
+ className: "min-w-0 flex-1 basis-full @min-[30rem]/access:basis-0"
249
+ }),
250
+ editable && grantButton("hidden @min-[30rem]/access:inline-flex")
251
+ ]
252
+ }),
253
+ failure && !failure.inGrant && !filtered.order.includes(anchorOf(failure, arrangement)) && /* @__PURE__ */ jsx("div", {
254
+ className: "px-4 pt-3",
255
+ children: failureNode
256
+ }),
257
+ /* @__PURE__ */ jsx("div", {
258
+ className: "min-h-0 flex-1 overflow-y-auto",
259
+ children: loading ? /* @__PURE__ */ jsx(LoadingRegion, {
260
+ label: t.loading,
261
+ children: /* @__PURE__ */ jsx(RowsSkeleton, {})
262
+ }) : empty ? /* @__PURE__ */ jsx(EmptyState, {
263
+ variant: "bare",
264
+ icon: /* @__PURE__ */ jsx(UsersIcon, {}),
265
+ title: t.emptyTitle,
266
+ description: t.emptyDescription(resourceName),
267
+ action: editable && grantButton(""),
268
+ className: "py-16"
269
+ }) : filtered.order.length === 0 ? /* @__PURE__ */ jsx(EmptyState, {
270
+ variant: "bare",
271
+ title: t.noMatches,
272
+ action: /* @__PURE__ */ jsx(Button, {
273
+ variant: "secondary",
274
+ size: "sm",
275
+ onClick: () => setQuery(""),
276
+ children: t.clearFilter
277
+ })
278
+ }) : /* @__PURE__ */ jsx(View, {
279
+ model,
280
+ shown: filtered.shown,
281
+ order: filtered.order,
282
+ expanded: expanded[arrangement],
283
+ onExpandedChange: (id, isOpen) => setExpanded((current) => {
284
+ const next = new Set(current[arrangement]);
285
+ if (isOpen) next.add(id);
286
+ else next.delete(id);
287
+ return {
288
+ ...current,
289
+ [arrangement]: next
290
+ };
291
+ }),
292
+ filtering: query.trim() !== "",
293
+ sectionId,
294
+ t,
295
+ actions: {
296
+ readOnly: !editable,
297
+ saving: savingKeys,
298
+ removal,
299
+ onAskRemove: (next) => {
300
+ holdFocus();
301
+ setFailure(void 0);
302
+ setRemoval(next);
303
+ },
304
+ onCancelRemove: () => {
305
+ const asked = removal;
306
+ setRemoval(void 0);
307
+ if (asked) focusLater(() => body.current?.querySelector(`[data-remove="${CSS.escape(removeKey(asked))}"]`));
308
+ },
309
+ onRemove: remove,
310
+ onGrantMore: (groupId) => toGrant([groupId]),
311
+ failure: failure && !failure.inGrant ? {
312
+ node: failureNode,
313
+ groupId: failure.groupId,
314
+ roleId: failure.roleId
315
+ } : void 0
316
+ }
317
+ })
318
+ }),
319
+ editable && !empty && /* @__PURE__ */ jsx("div", {
320
+ className: "border-t border-(--theme-border) p-4 @min-[30rem]/access:hidden",
321
+ children: grantButton("w-full")
322
+ })
323
+ ]
324
+ });
325
+ const granting = view === "grant";
326
+ return /* @__PURE__ */ jsx(Drawer, {
327
+ open,
328
+ onClose: close,
329
+ width,
330
+ padded: false,
331
+ title: granting ? t.grantAccess : t.title,
332
+ description: granting ? resourceName : loading ? resourceName : t.summary(resourceName, [...model.held.keys()].filter((id) => id !== model.everyone?.id).length, model.everyone !== void 0 && model.held.has(model.everyone.id)),
333
+ onBack: granting ? toList : void 0,
334
+ backLabel: t.back,
335
+ footer: granting ? grantView.footer : void 0,
336
+ onKeyDown: (e) => {
337
+ const target = e.target;
338
+ if (granting && e.key === "Escape" && !e.defaultPrevented && target.getAttribute("aria-expanded") !== "true") {
339
+ e.stopPropagation();
340
+ e.preventBaseUIHandler();
341
+ toList();
342
+ }
343
+ },
344
+ children: /* @__PURE__ */ jsx("div", {
345
+ ref: bodyRef,
346
+ className: "flex min-h-0 flex-1 flex-col",
347
+ children: granting ? grantView.body : list
348
+ })
349
+ });
350
+ }
351
+ var messageOf = (error) => error instanceof Error && error.message ? error.message : void 0;
352
+ var anchorOf = (failure, arrangement) => arrangement === "role" ? failure.roleId ?? "" : failure.groupId;
353
+ /** The first of several elements that is laid out, such as the one of two buttons a width shows. */
354
+ /** The one of `elements` that is laid out, or the first where nothing is (as in tests). */
355
+ function visible(elements) {
356
+ const all = [...elements ?? []];
357
+ return all.find((element) => element.getClientRects().length > 0) ?? all[0];
358
+ }
359
+ var groupNames = (group, t) => group.kind === "everyone" ? [
360
+ group.name,
361
+ t.everyone,
362
+ t.everyoneInOrganization
363
+ ] : [group.name];
364
+ /** The sections in view and the rows under each. A section named by the query shows all its
365
+ * rows; otherwise only the rows the query names, and no section when none are. */
366
+ function useFiltered(model, arrangement, query, match, t) {
367
+ return useMemo(() => filterAccess(model, arrangement, query, match, t), [
368
+ model,
369
+ arrangement,
370
+ query,
371
+ match,
372
+ t
373
+ ]);
374
+ }
375
+ function filterAccess(model, arrangement, query, match, t) {
376
+ const shown = /* @__PURE__ */ new Map();
377
+ const order = [];
378
+ if (arrangement === "role") for (const role of model.roles) {
379
+ const holders = model.holders.get(role.id) ?? [];
380
+ const rows = !query || match(query, role.name, role.description) ? holders : holders.filter((group) => match(query, ...groupNames(group, t)));
381
+ if (!query || rows.length > 0) {
382
+ order.push(role.id);
383
+ shown.set(role.id, rows.map((group) => group.id));
384
+ }
385
+ }
386
+ else {
387
+ const everyone = model.everyone;
388
+ const holders = [...model.held.keys()].sort((a, b) => a === everyone?.id ? -1 : b === everyone?.id ? 1 : 0);
389
+ for (const groupId of holders) {
390
+ const group = model.groupById.get(groupId);
391
+ const held = model.held.get(groupId) ?? [];
392
+ if (!group) continue;
393
+ const rows = !query || match(query, ...groupNames(group, t)) ? held : held.filter((role) => match(query, role.name, role.description));
394
+ if (!query || rows.length > 0) {
395
+ order.push(groupId);
396
+ shown.set(groupId, rows.map((role) => role.id));
397
+ }
398
+ }
399
+ }
400
+ return {
401
+ shown,
402
+ order
403
+ };
404
+ }
405
+ /** Opens the sections a query found rows in, so the matches show; each can then be folded. */
406
+ function expandedFor(model, query, match, t) {
407
+ if (!query) return {
408
+ role: /* @__PURE__ */ new Set(),
409
+ group: /* @__PURE__ */ new Set()
410
+ };
411
+ const open = (arrangement) => {
412
+ const { shown } = filterAccess(model, arrangement, query, match, t);
413
+ return new Set([...shown].filter(([, rows]) => rows.length > 0).map(([id]) => id));
414
+ };
415
+ return {
416
+ role: open("role"),
417
+ group: open("group")
418
+ };
419
+ }
420
+ function RowsSkeleton() {
421
+ return [
422
+ "a",
423
+ "b",
424
+ "c",
425
+ "d"
426
+ ].map((row) => /* @__PURE__ */ jsxs("div", {
427
+ className: "flex items-center gap-3 border-b border-(--theme-border) px-4 py-3",
428
+ children: [
429
+ /* @__PURE__ */ jsx(Skeleton, {
430
+ width: 16,
431
+ height: 16
432
+ }),
433
+ /* @__PURE__ */ jsxs("div", {
434
+ className: "flex min-w-0 flex-1 flex-col gap-1.5",
435
+ children: [/* @__PURE__ */ jsx(Skeleton, { width: "30%" }), /* @__PURE__ */ jsx(Skeleton, {
436
+ width: "60%",
437
+ className: "text-sm"
438
+ })]
439
+ }),
440
+ /* @__PURE__ */ jsx(Skeleton, { width: 56 })
441
+ ]
442
+ }, row));
443
+ }
444
+ //#endregion
445
+ export { AccessDrawer, filterAccess };
@@ -0,0 +1,58 @@
1
+ import { type ReactNode } from 'react';
2
+ import { type AccessModel } from './model.js';
3
+ import type { AccessStrings } from './strings.js';
4
+ /** A removal waiting on the reader: one role, or every role the group holds. */
5
+ export interface PendingRemoval {
6
+ groupId: string;
7
+ roleIds: string[];
8
+ /** Every role the group holds, from its "Remove all access". */
9
+ all: boolean;
10
+ }
11
+ export interface ListActions {
12
+ readOnly: boolean;
13
+ /** The grants being saved, by `key()`. */
14
+ saving: ReadonlySet<string>;
15
+ removal: PendingRemoval | undefined;
16
+ onAskRemove: (removal: PendingRemoval) => void;
17
+ onCancelRemove: () => void;
18
+ onRemove: (removal: PendingRemoval) => void;
19
+ onGrantMore: (groupId: string) => void;
20
+ /** The failed save's callout, and where it belongs: under the role or group it was about. */
21
+ failure: {
22
+ node: ReactNode;
23
+ roleId?: string | undefined;
24
+ groupId: string;
25
+ } | undefined;
26
+ }
27
+ export declare function HighImpact({ t }: {
28
+ t: AccessStrings;
29
+ }): import("react").JSX.Element;
30
+ /** Asks before a group loses roles, saying exactly what it loses and what it keeps. */
31
+ export declare function RemoveConfirm({ model, removal, saving, onCancel, onConfirm, t, }: {
32
+ model: AccessModel;
33
+ removal: PendingRemoval;
34
+ saving: boolean;
35
+ onCancel: () => void;
36
+ onConfirm: () => void;
37
+ t: AccessStrings;
38
+ }): import("react").JSX.Element | null;
39
+ /** Marks the button that asked for a removal, so focus can go back to it. */
40
+ export declare const removeKey: (removal: PendingRemoval) => string;
41
+ interface ViewProps {
42
+ model: AccessModel;
43
+ /** What the filter left: each role's groups, or each group's roles. */
44
+ shown: ReadonlyMap<string, readonly string[]>;
45
+ /** The rows in view, in order. */
46
+ order: readonly string[];
47
+ expanded: ReadonlySet<string>;
48
+ onExpandedChange: (id: string, open: boolean) => void;
49
+ filtering: boolean;
50
+ actions: ListActions;
51
+ t: AccessStrings;
52
+ sectionId: (id: string) => string;
53
+ }
54
+ /** Who has each role, role by role. */
55
+ export declare function ByRole({ model, shown, order, expanded, onExpandedChange, filtering, actions, t, sectionId, }: ViewProps): (import("react").JSX.Element | null)[];
56
+ /** Which roles each group has, group by group. */
57
+ export declare function ByGroup({ model, shown, order, expanded, onExpandedChange, actions, t, sectionId, }: ViewProps): (import("react").JSX.Element | null)[];
58
+ export {};