@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.
- package/README.md +68 -9
- package/dist/access/AccessDrawer.d.ts +55 -0
- package/dist/access/AccessDrawer.js +445 -0
- package/dist/access/AccessList.d.ts +58 -0
- package/dist/access/AccessList.js +382 -0
- package/dist/access/GrantView.d.ts +35 -0
- package/dist/access/GrantView.js +188 -0
- package/dist/access/GroupPicker.d.ts +35 -0
- package/dist/access/GroupPicker.js +336 -0
- package/dist/access/focus.d.ts +10 -0
- package/dist/access/focus.js +27 -0
- package/dist/access/index.d.ts +3 -0
- package/dist/access/index.js +3 -0
- package/dist/access/model.d.ts +49 -0
- package/dist/access/model.js +129 -0
- package/dist/access/strings.d.ts +122 -0
- package/dist/access/strings.js +151 -0
- package/dist/access/types.d.ts +56 -0
- package/dist/ai/components/SlashMenu.js +1 -1
- package/dist/components/Combobox.d.ts +19 -0
- package/dist/components/Combobox.js +101 -50
- package/dist/components/DatePicker.js +4 -5
- package/dist/components/Drawer.d.ts +6 -1
- package/dist/components/Drawer.js +25 -16
- package/dist/components/Field.d.ts +8 -3
- package/dist/components/Field.js +32 -12
- package/dist/components/InfoTip.d.ts +3 -1
- package/dist/components/InfoTip.js +2 -1
- package/dist/components/Menu.js +4 -4
- package/dist/components/MenuEntries.js +1 -1
- package/dist/components/OptionDescription.d.ts +7 -0
- package/dist/components/OptionDescription.js +19 -0
- package/dist/components/Popover.d.ts +4 -1
- package/dist/components/Popover.js +2 -2
- package/dist/components/Provider.d.ts +7 -0
- package/dist/components/Provider.js +3 -0
- package/dist/components/Select.js +13 -11
- package/dist/components/comboboxItems.d.ts +10 -0
- package/dist/components/comboboxItems.js +8 -1
- package/dist/components/floating.d.ts +5 -2
- package/dist/components/floating.js +2 -2
- package/dist/editor/EditorField.js +1 -2
- package/dist/editor/Monaco.js +26 -2
- package/dist/editor/themes.d.ts +5 -0
- package/dist/editor/themes.js +18 -3
- package/dist/file-explorer/FileExplorer.js +2 -1
- package/dist/file-explorer/lib/paging.d.ts +1 -0
- package/dist/file-explorer/lib/paging.js +5 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +2 -2
- package/dist/styles/theme.css +82 -62
- package/dist/styles.css +1 -1
- package/dist/theme/derive.d.ts +5 -3
- package/dist/theme/derive.js +74 -4
- package/package.json +6 -2
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
|
|
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
|
|
@@ -707,11 +757,20 @@ animations) and streamdown's classes next to the theme contract:
|
|
|
707
757
|
|
|
708
758
|
The prebuilt `@parallelworks/ui/styles.css` already includes both.
|
|
709
759
|
|
|
710
|
-
|
|
711
|
-
itself, from the `katex` package, so the host's bundler loads it and its fonts
|
|
760
|
+
For math, `Markdown` imports KaTeX's stylesheet itself, from the `katex` package, so the host's bundler loads it and its fonts
|
|
712
761
|
with the markdown renderer's lazy chunk. The bundler must accept CSS imported
|
|
713
|
-
from JavaScript, as the editor's Monaco also requires.
|
|
714
|
-
|
|
762
|
+
from JavaScript, as the editor's Monaco also requires. The renderer's math
|
|
763
|
+
plugins still ask for katex 0.16, which has a prototype-pollution advisory;
|
|
764
|
+
override them to the katex this package asks for, so they render with the copy
|
|
765
|
+
the stylesheet comes from, as with pnpm:
|
|
766
|
+
|
|
767
|
+
```yaml
|
|
768
|
+
overrides:
|
|
769
|
+
'katex@<0.19.0': '^0.19.0'
|
|
770
|
+
```
|
|
771
|
+
|
|
772
|
+
Inline math takes `$$` within a sentence; a single `$` stays text, so prices
|
|
773
|
+
are never read as math.
|
|
715
774
|
|
|
716
775
|
## Form state
|
|
717
776
|
|
|
@@ -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 {};
|