cortena-ui 1.4.2 → 1.6.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/CHANGELOG.md +107 -0
- package/LICENSE +7 -0
- package/README.md +235 -3
- package/dist/a2ui/views.js +2 -2
- package/dist/agent-chat/a2ui-block.d.ts +60 -0
- package/dist/agent-chat/a2ui-block.js +69 -0
- package/dist/agent-chat/a2ui-block.js.map +1 -0
- package/dist/agent-chat/agui-client.d.ts +40 -0
- package/dist/agent-chat/agui-client.js +251 -0
- package/dist/agent-chat/agui-client.js.map +1 -0
- package/dist/agent-chat/bridge.d.ts +109 -0
- package/dist/agent-chat/bridge.js +353 -0
- package/dist/agent-chat/bridge.js.map +1 -0
- package/dist/agent-chat/session.d.ts +79 -0
- package/dist/agent-chat/session.js +391 -0
- package/dist/agent-chat/session.js.map +1 -0
- package/dist/agent-chat/step-label.d.ts +99 -0
- package/dist/agent-chat/step-label.js +116 -0
- package/dist/agent-chat/step-label.js.map +1 -0
- package/dist/agent-chat/store.d.ts +102 -0
- package/dist/agent-chat/store.js +876 -0
- package/dist/agent-chat/store.js.map +1 -0
- package/dist/agent-chat/types.d.ts +277 -0
- package/dist/agent-chat/types.js +17 -0
- package/dist/agent-chat/types.js.map +1 -0
- package/dist/agent-chat.d.ts +11 -0
- package/dist/agent-chat.js +11 -0
- package/dist/components/admin-permissions/admin-permissions.d.ts +66 -0
- package/dist/components/admin-permissions/admin-permissions.js +101 -0
- package/dist/components/admin-permissions/admin-permissions.js.map +1 -0
- package/dist/components/admin-permissions/context.d.ts +70 -0
- package/dist/components/admin-permissions/context.js +258 -0
- package/dist/components/admin-permissions/context.js.map +1 -0
- package/dist/components/admin-permissions/index.d.ts +10 -0
- package/dist/components/admin-permissions/licence.d.ts +15 -0
- package/dist/components/admin-permissions/licence.js +78 -0
- package/dist/components/admin-permissions/licence.js.map +1 -0
- package/dist/components/admin-permissions/matrix.d.ts +20 -0
- package/dist/components/admin-permissions/matrix.js +191 -0
- package/dist/components/admin-permissions/matrix.js.map +1 -0
- package/dist/components/admin-permissions/members.d.ts +18 -0
- package/dist/components/admin-permissions/members.js +185 -0
- package/dist/components/admin-permissions/members.js.map +1 -0
- package/dist/components/admin-permissions/role-assignment.d.ts +35 -0
- package/dist/components/admin-permissions/role-assignment.js +174 -0
- package/dist/components/admin-permissions/role-assignment.js.map +1 -0
- package/dist/components/admin-permissions/roles.d.ts +25 -0
- package/dist/components/admin-permissions/roles.js +168 -0
- package/dist/components/admin-permissions/roles.js.map +1 -0
- package/dist/components/admin-permissions/types.d.ts +152 -0
- package/dist/components/admin-permissions/types.js +63 -0
- package/dist/components/admin-permissions/types.js.map +1 -0
- package/dist/components/agent-chat-popup.d.ts +29 -0
- package/dist/components/agent-chat-popup.js +188 -0
- package/dist/components/agent-chat-popup.js.map +1 -0
- package/dist/components/agent-chat.d.ts +163 -0
- package/dist/components/agent-chat.js +673 -0
- package/dist/components/agent-chat.js.map +1 -0
- package/dist/components/app-shell.d.ts +126 -0
- package/dist/components/app-shell.js +297 -0
- package/dist/components/app-shell.js.map +1 -0
- package/dist/components/badge.d.ts +1 -1
- package/dist/components/button-link.js +1 -1
- package/dist/components/button.d.ts +1 -1
- package/dist/components/checkbox.d.ts +1 -1
- package/dist/components/combobox.d.ts +1 -1
- package/dist/components/combobox.js +1 -1
- package/dist/components/consent-screen.d.ts +65 -0
- package/dist/components/consent-screen.js +123 -0
- package/dist/components/consent-screen.js.map +1 -0
- package/dist/components/data-table/data-table.d.ts +15 -1
- package/dist/components/data-table/data-table.js +18 -4
- package/dist/components/data-table/data-table.js.map +1 -1
- package/dist/components/data-table/index.d.ts +4 -4
- package/dist/components/data-table/parts.d.ts +27 -3
- package/dist/components/data-table/parts.js +175 -55
- package/dist/components/data-table/parts.js.map +1 -1
- package/dist/components/data-table/types.d.ts +61 -0
- package/dist/components/data-table/use-data-table.js +91 -6
- package/dist/components/data-table/use-data-table.js.map +1 -1
- package/dist/components/data-table/use-server-source.js +119 -28
- package/dist/components/data-table/use-server-source.js.map +1 -1
- package/dist/components/help-panel.d.ts +131 -0
- package/dist/components/help-panel.js +545 -0
- package/dist/components/help-panel.js.map +1 -0
- package/dist/components/login-screen.d.ts +127 -0
- package/dist/components/login-screen.js +339 -0
- package/dist/components/login-screen.js.map +1 -0
- package/dist/components/session-guard.d.ts +268 -0
- package/dist/components/session-guard.js +632 -0
- package/dist/components/session-guard.js.map +1 -0
- package/dist/components/toast.d.ts +1 -1
- package/dist/core.d.ts +5 -1
- package/dist/core.js +11 -7
- package/dist/data-table.d.ts +13 -4
- package/dist/data-table.js +10 -2
- package/dist/hooks/use-cortena-theme.js +49 -3
- package/dist/hooks/use-cortena-theme.js.map +1 -1
- package/dist/index.d.ts +17 -4
- package/dist/index.js +21 -8
- package/dist/markdown.d.ts +2 -1
- package/dist/markdown.js +2 -1
- package/package.json +18 -5
- package/src/agent-chat/a2ui-block.ts +118 -0
- package/src/agent-chat/agui-client.ts +405 -0
- package/src/agent-chat/bridge.ts +445 -0
- package/src/agent-chat/session.ts +549 -0
- package/src/agent-chat/step-label.ts +177 -0
- package/src/agent-chat/store.ts +1234 -0
- package/src/agent-chat/types.ts +308 -0
- package/src/components/admin-permissions/admin-permissions.tsx +130 -0
- package/src/components/admin-permissions/context.tsx +376 -0
- package/src/components/admin-permissions/index.tsx +32 -0
- package/src/components/admin-permissions/licence.tsx +84 -0
- package/src/components/admin-permissions/matrix.tsx +257 -0
- package/src/components/admin-permissions/members.tsx +204 -0
- package/src/components/admin-permissions/role-assignment.tsx +239 -0
- package/src/components/admin-permissions/roles.tsx +169 -0
- package/src/components/admin-permissions/types.ts +231 -0
- package/src/components/agent-chat-popup.tsx +289 -0
- package/src/components/agent-chat.tsx +1006 -0
- package/src/components/app-shell.tsx +502 -0
- package/src/components/consent-screen.tsx +239 -0
- package/src/components/data-table/data-table.tsx +36 -0
- package/src/components/data-table/index.tsx +6 -1
- package/src/components/data-table/parts.tsx +223 -47
- package/src/components/data-table/types.ts +68 -0
- package/src/components/data-table/use-data-table.ts +152 -4
- package/src/components/data-table/use-server-source.ts +150 -12
- package/src/components/help-panel.tsx +765 -0
- package/src/components/login-screen.tsx +479 -0
- package/src/components/session-guard.tsx +1071 -0
- package/src/entries/agent-chat.ts +137 -0
- package/src/entries/core.ts +8 -0
- package/src/entries/data-table.ts +41 -0
- package/src/entries/markdown.ts +25 -0
- package/src/hooks/use-cortena-theme.ts +63 -4
- package/src/index.ts +6 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
"use client";
|
|
3
|
+
import { cn } from "../../lib/cn.js";
|
|
4
|
+
import { PageHeader } from "../page-header.js";
|
|
5
|
+
import { Tabs, TabsList, TabsTrigger } from "../tabs.js";
|
|
6
|
+
import { ADMIN_ROUTES, ADMIN_ROUTE_LABELS } from "./types.js";
|
|
7
|
+
import { AdminPermissionsProvider } from "./context.js";
|
|
8
|
+
import { AdminLicenceScreen } from "./licence.js";
|
|
9
|
+
import { AdminMatrix } from "./matrix.js";
|
|
10
|
+
import { AdminMembers } from "./members.js";
|
|
11
|
+
import { AdminRoles } from "./roles.js";
|
|
12
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
13
|
+
import * as React from "react";
|
|
14
|
+
//#region src/components/admin-permissions/admin-permissions.tsx
|
|
15
|
+
/**
|
|
16
|
+
* AdminPermissions — the consistent admin screen for every Cortena extension
|
|
17
|
+
* (§13, DESIGN-D11 as amended by DESIGN-D22; audit rules P-14, P-42, P-43).
|
|
18
|
+
*
|
|
19
|
+
* Four screens behind `admin/*`: `/admin/members`, `/admin/roles`,
|
|
20
|
+
* `/admin/licence`, `/admin/permissions`. It renders whatever roles and
|
|
21
|
+
* actions the extension declares and **defines none of its own** — there is
|
|
22
|
+
* not one role name, action name or grant in this folder. That is exactly what
|
|
23
|
+
* lets a verification workflow and a work tracker have completely different
|
|
24
|
+
* role models and still look like one product.
|
|
25
|
+
*
|
|
26
|
+
* ```tsx
|
|
27
|
+
* <Route path="admin/*" element={
|
|
28
|
+
* <AdminPermissions
|
|
29
|
+
* api={adminApi}
|
|
30
|
+
* route={screenFromPath(location.pathname)}
|
|
31
|
+
* onNavigate={(next) => navigate(`/admin/${next}`)}
|
|
32
|
+
* />
|
|
33
|
+
* } />
|
|
34
|
+
* ```
|
|
35
|
+
*
|
|
36
|
+
* **Router-agnostic, by a `route` prop plus `onNavigate`.** The component
|
|
37
|
+
* imports no router and never touches `history` or `location`. Those four
|
|
38
|
+
* paths are real URLs — deep-linkable, bookmarkable, and answerable to the
|
|
39
|
+
* back button — and only the host's router can own a URL; a component that
|
|
40
|
+
* pushed history itself would fight whichever router the extension chose.
|
|
41
|
+
* `onNavigate` reports the intent, the host turns it into a navigation, and
|
|
42
|
+
* `route` comes back as the answer.
|
|
43
|
+
*
|
|
44
|
+
* An internal fallback exists for the case with no router in it — the guide,
|
|
45
|
+
* a test, a small app: omit `route` and the composition keeps the screen in
|
|
46
|
+
* its own state. `onNavigate` still fires, so a host can start uncontrolled
|
|
47
|
+
* and add a router later without changing anything else.
|
|
48
|
+
*
|
|
49
|
+
* The navigation is a real tablist rather than a row of links, because the
|
|
50
|
+
* choice is between panels of one screen; the host is free to hide it with
|
|
51
|
+
* `header={false}` and drive `route` from its own chrome.
|
|
52
|
+
*/
|
|
53
|
+
function AdminPermissions({ api, route, defaultRoute = "members", onNavigate, header = true, title = "Administration", description = "Who is in this organisation, what your roles let them do, and the licence behind it.", className, ...props }) {
|
|
54
|
+
const [internal, setInternal] = React.useState(defaultRoute);
|
|
55
|
+
const active = route ?? internal;
|
|
56
|
+
const go = (next) => {
|
|
57
|
+
if (route === void 0) setInternal(next);
|
|
58
|
+
onNavigate?.(next);
|
|
59
|
+
};
|
|
60
|
+
return /* @__PURE__ */ jsx(AdminPermissionsProvider, {
|
|
61
|
+
api,
|
|
62
|
+
children: /* @__PURE__ */ jsxs("div", {
|
|
63
|
+
"data-slot": "admin-permissions",
|
|
64
|
+
"data-route": active,
|
|
65
|
+
className: cn("flex flex-col gap-5", className),
|
|
66
|
+
...props,
|
|
67
|
+
children: [
|
|
68
|
+
header ? /* @__PURE__ */ jsx(PageHeader, {
|
|
69
|
+
title,
|
|
70
|
+
description
|
|
71
|
+
}) : null,
|
|
72
|
+
/* @__PURE__ */ jsx(Tabs, {
|
|
73
|
+
value: active,
|
|
74
|
+
onValueChange: (value) => go(value),
|
|
75
|
+
"data-slot": "admin-permissions-nav",
|
|
76
|
+
children: /* @__PURE__ */ jsx(TabsList, {
|
|
77
|
+
"aria-label": "Administration screens",
|
|
78
|
+
children: ADMIN_ROUTES.map((item) => /* @__PURE__ */ jsx(TabsTrigger, {
|
|
79
|
+
value: item,
|
|
80
|
+
"data-route": item,
|
|
81
|
+
children: ADMIN_ROUTE_LABELS[item]
|
|
82
|
+
}, item))
|
|
83
|
+
})
|
|
84
|
+
}),
|
|
85
|
+
/* @__PURE__ */ jsxs("div", {
|
|
86
|
+
"data-slot": "admin-permissions-screen",
|
|
87
|
+
children: [
|
|
88
|
+
active === "members" ? /* @__PURE__ */ jsx(AdminMembers, {}) : null,
|
|
89
|
+
active === "roles" ? /* @__PURE__ */ jsx(AdminRoles, {}) : null,
|
|
90
|
+
active === "licence" ? /* @__PURE__ */ jsx(AdminLicenceScreen, {}) : null,
|
|
91
|
+
active === "permissions" ? /* @__PURE__ */ jsx(AdminMatrix, {}) : null
|
|
92
|
+
]
|
|
93
|
+
})
|
|
94
|
+
]
|
|
95
|
+
})
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
//#endregion
|
|
99
|
+
export { AdminPermissions };
|
|
100
|
+
|
|
101
|
+
//# sourceMappingURL=admin-permissions.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"admin-permissions.js","names":[],"sources":["../../../src/components/admin-permissions/admin-permissions.tsx"],"sourcesContent":["\"use client\";\n\nimport * as React from \"react\";\nimport { PageHeader } from \"@/components/page-header\";\nimport { Tabs, TabsList, TabsTrigger } from \"@/components/tabs\";\nimport { cn } from \"@/lib/cn\";\nimport { AdminPermissionsProvider } from \"./context\";\nimport { AdminLicenceScreen } from \"./licence\";\nimport { AdminMatrix } from \"./matrix\";\nimport { AdminMembers } from \"./members\";\nimport { AdminRoles } from \"./roles\";\nimport {\n ADMIN_ROUTES,\n ADMIN_ROUTE_LABELS,\n type AdminPermissionsApi,\n type AdminRoute,\n} from \"./types\";\n\nexport interface AdminPermissionsProps\n extends Omit<React.ComponentProps<\"div\">, \"children\" | \"title\"> {\n /** Everything the screen reads and writes. The extension's, entirely. */\n api: AdminPermissionsApi;\n /**\n * The screen to show. Pass it and the host's router owns the URL; omit it\n * and the composition keeps the current screen in its own state.\n */\n route?: AdminRoute;\n /** The screen to open with when `route` is not passed. @default \"members\" */\n defaultRoute?: AdminRoute;\n /**\n * Fired when the administrator picks another screen. Always fired, whether\n * or not `route` is controlled, so a host can push the URL without also\n * having to hold the state.\n */\n onNavigate?: (route: AdminRoute) => void;\n /** `false` hides the title block, for a host that renders its own. */\n header?: boolean;\n title?: React.ReactNode;\n description?: React.ReactNode;\n}\n\n/**\n * AdminPermissions — the consistent admin screen for every Cortena extension\n * (§13, DESIGN-D11 as amended by DESIGN-D22; audit rules P-14, P-42, P-43).\n *\n * Four screens behind `admin/*`: `/admin/members`, `/admin/roles`,\n * `/admin/licence`, `/admin/permissions`. It renders whatever roles and\n * actions the extension declares and **defines none of its own** — there is\n * not one role name, action name or grant in this folder. That is exactly what\n * lets a verification workflow and a work tracker have completely different\n * role models and still look like one product.\n *\n * ```tsx\n * <Route path=\"admin/*\" element={\n * <AdminPermissions\n * api={adminApi}\n * route={screenFromPath(location.pathname)}\n * onNavigate={(next) => navigate(`/admin/${next}`)}\n * />\n * } />\n * ```\n *\n * **Router-agnostic, by a `route` prop plus `onNavigate`.** The component\n * imports no router and never touches `history` or `location`. Those four\n * paths are real URLs — deep-linkable, bookmarkable, and answerable to the\n * back button — and only the host's router can own a URL; a component that\n * pushed history itself would fight whichever router the extension chose.\n * `onNavigate` reports the intent, the host turns it into a navigation, and\n * `route` comes back as the answer.\n *\n * An internal fallback exists for the case with no router in it — the guide,\n * a test, a small app: omit `route` and the composition keeps the screen in\n * its own state. `onNavigate` still fires, so a host can start uncontrolled\n * and add a router later without changing anything else.\n *\n * The navigation is a real tablist rather than a row of links, because the\n * choice is between panels of one screen; the host is free to hide it with\n * `header={false}` and drive `route` from its own chrome.\n */\nexport function AdminPermissions({\n api,\n route,\n defaultRoute = \"members\",\n onNavigate,\n header = true,\n title = \"Administration\",\n description = \"Who is in this organisation, what your roles let them do, and the licence behind it.\",\n className,\n ...props\n}: AdminPermissionsProps) {\n const [internal, setInternal] = React.useState<AdminRoute>(defaultRoute);\n const active = route ?? internal;\n\n const go = (next: AdminRoute) => {\n if (route === undefined) setInternal(next);\n onNavigate?.(next);\n };\n\n return (\n <AdminPermissionsProvider api={api}>\n <div\n data-slot=\"admin-permissions\"\n data-route={active}\n className={cn(\"flex flex-col gap-5\", className)}\n {...props}\n >\n {header ? <PageHeader title={title} description={description} /> : null}\n <Tabs\n value={active}\n onValueChange={(value) => go(value as AdminRoute)}\n data-slot=\"admin-permissions-nav\"\n >\n <TabsList aria-label=\"Administration screens\">\n {ADMIN_ROUTES.map((item) => (\n <TabsTrigger key={item} value={item} data-route={item}>\n {ADMIN_ROUTE_LABELS[item]}\n </TabsTrigger>\n ))}\n </TabsList>\n </Tabs>\n <div data-slot=\"admin-permissions-screen\">\n {active === \"members\" ? <AdminMembers /> : null}\n {active === \"roles\" ? <AdminRoles /> : null}\n {active === \"licence\" ? <AdminLicenceScreen /> : null}\n {active === \"permissions\" ? <AdminMatrix /> : null}\n </div>\n </div>\n </AdminPermissionsProvider>\n );\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA+EA,SAAgB,iBAAiB,EAC/B,KACA,OACA,eAAe,WACf,YACA,SAAS,MACT,QAAQ,kBACR,cAAc,wFACd,WACA,GAAG,SACqB;CACxB,MAAM,CAAC,UAAU,eAAe,MAAM,SAAqB,YAAY;CACvE,MAAM,SAAS,SAAS;CAExB,MAAM,MAAM,SAAqB;EAC/B,IAAI,UAAU,KAAA,GAAW,YAAY,IAAI;EACzC,aAAa,IAAI;CACnB;CAEA,OACE,oBAAC,0BAAD;EAA+B;EAC7B,UAAA,qBAAC,OAAD;GACE,aAAU;GACV,cAAY;GACZ,WAAW,GAAG,uBAAuB,SAAS;GAC9C,GAAI;GAJN,UAAA;IAMG,SAAS,oBAAC,YAAD;KAAmB;KAAoB;IAAc,CAAA,IAAI;IACnE,oBAAC,MAAD;KACE,OAAO;KACP,gBAAgB,UAAU,GAAG,KAAmB;KAChD,aAAU;KAEV,UAAA,oBAAC,UAAD;MAAU,cAAW;MAClB,UAAA,aAAa,KAAK,SACjB,oBAAC,aAAD;OAAwB,OAAO;OAAM,cAAY;OAC9C,UAAA,mBAAmB;MACT,GAFK,IAEL,CACd;KACO,CAAA;IACN,CAAA;IACN,qBAAC,OAAD;KAAK,aAAU;KAAf,UAAA;MACG,WAAW,YAAY,oBAAC,cAAD,CAAe,CAAA,IAAI;MAC1C,WAAW,UAAU,oBAAC,YAAD,CAAa,CAAA,IAAI;MACtC,WAAW,YAAY,oBAAC,oBAAD,CAAqB,CAAA,IAAI;MAChD,WAAW,gBAAgB,oBAAC,aAAD,CAAc,CAAA,IAAI;KAC3C;;GACF;;CACmB,CAAA;AAE9B"}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { AdminGroupRow, AdminMemberRow, AdminPermissionMatrix, AdminPermissionsApi, AdminPrincipal } from "./types.js";
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
//#region src/components/admin-permissions/context.d.ts
|
|
5
|
+
/**
|
|
6
|
+
* One async read, with the abort a screen needs when it unmounts mid-flight.
|
|
7
|
+
* Small on purpose: the composition fetches three documents of its own (the
|
|
8
|
+
* matrix, the licence, the groups) and the members list is a DataTable on the
|
|
9
|
+
* server source, which owns its own fetching.
|
|
10
|
+
*/
|
|
11
|
+
export interface Resource<T> {
|
|
12
|
+
data: T | null;
|
|
13
|
+
error: Error | null;
|
|
14
|
+
loading: boolean;
|
|
15
|
+
reload: () => void;
|
|
16
|
+
}
|
|
17
|
+
export interface AdminPermissionsContextValue {
|
|
18
|
+
api: AdminPermissionsApi;
|
|
19
|
+
/** The declared vocabulary and matrix. `null` until it loads, or when it failed. */
|
|
20
|
+
matrix: AdminPermissionMatrix | null;
|
|
21
|
+
matrixState: Resource<AdminPermissionMatrix>;
|
|
22
|
+
/** Write the matrix. Optimistic: the grid shows the new grants before the PUT lands. */
|
|
23
|
+
saveMatrix: (next: AdminPermissionMatrix) => Promise<void>;
|
|
24
|
+
/**
|
|
25
|
+
* True when the API offers groups *and* the org actually has some. Every
|
|
26
|
+
* group affordance is hidden otherwise.
|
|
27
|
+
*/
|
|
28
|
+
hasGroups: boolean;
|
|
29
|
+
/**
|
|
30
|
+
* True while the answer to `hasGroups` is not known yet — the list is in
|
|
31
|
+
* flight, or it failed.
|
|
32
|
+
*
|
|
33
|
+
* `hasGroups === false` used to mean three different things at once: this
|
|
34
|
+
* API has no groups, this org has none, and `groups.list()` blew up. The
|
|
35
|
+
* third renders identically to the second, so a member who inherits
|
|
36
|
+
* "reviewer" from Legal is shown with no roles at all and an administrator
|
|
37
|
+
* reads that as a fact about the member. A failure has to look like a
|
|
38
|
+
* failure.
|
|
39
|
+
*/
|
|
40
|
+
groupsUnknown: boolean;
|
|
41
|
+
groups: AdminGroupRow[];
|
|
42
|
+
groupsState: Resource<{
|
|
43
|
+
rows: AdminGroupRow[];
|
|
44
|
+
roles: Record<string, string[]>;
|
|
45
|
+
}>;
|
|
46
|
+
/** group id → the roles it carries, including any not yet confirmed by the server. */
|
|
47
|
+
groupRoles: Readonly<Record<string, string[]>>;
|
|
48
|
+
/** Bumped after a successful assignment, so the members source refetches. */
|
|
49
|
+
revision: number;
|
|
50
|
+
/** Direct member roles written since the last refetch, by user id. */
|
|
51
|
+
memberRoles: ReadonlyMap<string, string[]>;
|
|
52
|
+
/**
|
|
53
|
+
* Assign a principal's direct roles. Optimistic: the screen shows the new
|
|
54
|
+
* roles immediately and puts the old ones back if the call rejects, then
|
|
55
|
+
* rethrows so the caller can show the envelope's message.
|
|
56
|
+
*/
|
|
57
|
+
assignRoles: (principal: AdminPrincipal, roles: string[]) => Promise<void>;
|
|
58
|
+
/**
|
|
59
|
+
* Hand back rows the server has just answered with, so any optimistic entry
|
|
60
|
+
* the server now agrees with can be dropped. See `memberRoles`.
|
|
61
|
+
*/
|
|
62
|
+
reconcileMembers: (rows: readonly AdminMemberRow[]) => void;
|
|
63
|
+
}
|
|
64
|
+
export declare function useAdminPermissions(): AdminPermissionsContextValue;
|
|
65
|
+
export declare function AdminPermissionsProvider({ api, children }: {
|
|
66
|
+
api: AdminPermissionsApi;
|
|
67
|
+
children: React.ReactNode;
|
|
68
|
+
}): React.JSX.Element;
|
|
69
|
+
//#endregion
|
|
70
|
+
//# sourceMappingURL=context.d.ts.map
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
"use client";
|
|
3
|
+
import { adminErrorMessage, effectiveRolesOf } from "./types.js";
|
|
4
|
+
import { jsx } from "react/jsx-runtime";
|
|
5
|
+
import * as React from "react";
|
|
6
|
+
//#region src/components/admin-permissions/context.tsx
|
|
7
|
+
function useResource(load, deps) {
|
|
8
|
+
const [state, setState] = React.useState({
|
|
9
|
+
data: null,
|
|
10
|
+
error: null,
|
|
11
|
+
loading: Boolean(load)
|
|
12
|
+
});
|
|
13
|
+
const [tick, setTick] = React.useState(0);
|
|
14
|
+
const loadRef = React.useRef(load);
|
|
15
|
+
React.useEffect(() => {
|
|
16
|
+
loadRef.current = load;
|
|
17
|
+
});
|
|
18
|
+
React.useEffect(() => {
|
|
19
|
+
const run = loadRef.current;
|
|
20
|
+
if (!run) {
|
|
21
|
+
setState({
|
|
22
|
+
data: null,
|
|
23
|
+
error: null,
|
|
24
|
+
loading: false
|
|
25
|
+
});
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
const controller = new AbortController();
|
|
29
|
+
setState((s) => ({
|
|
30
|
+
...s,
|
|
31
|
+
loading: true
|
|
32
|
+
}));
|
|
33
|
+
run(controller.signal).then((data) => {
|
|
34
|
+
if (!controller.signal.aborted) setState({
|
|
35
|
+
data,
|
|
36
|
+
error: null,
|
|
37
|
+
loading: false
|
|
38
|
+
});
|
|
39
|
+
}, (err) => {
|
|
40
|
+
if (controller.signal.aborted) return;
|
|
41
|
+
setState({
|
|
42
|
+
data: null,
|
|
43
|
+
error: new Error(adminErrorMessage(err)),
|
|
44
|
+
loading: false
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
return () => controller.abort();
|
|
48
|
+
}, [...deps, tick]);
|
|
49
|
+
return {
|
|
50
|
+
...state,
|
|
51
|
+
reload: React.useCallback(() => setTick((t) => t + 1), [])
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
/** Same roles, order-insensitively — which is all an assignment is. */
|
|
55
|
+
function sameRoles(a, b) {
|
|
56
|
+
if (a.length !== b.length) return false;
|
|
57
|
+
const left = [...a].sort();
|
|
58
|
+
const right = [...b].sort();
|
|
59
|
+
return left.every((role, i) => role === right[i]);
|
|
60
|
+
}
|
|
61
|
+
const AdminPermissionsContext = /* @__PURE__ */ React.createContext(null);
|
|
62
|
+
function useAdminPermissions() {
|
|
63
|
+
const value = React.useContext(AdminPermissionsContext);
|
|
64
|
+
if (!value) throw new Error("AdminPermissions parts must be rendered inside <AdminPermissions>");
|
|
65
|
+
return value;
|
|
66
|
+
}
|
|
67
|
+
const NO_GROUP_ROLES = /* @__PURE__ */ Object.freeze({});
|
|
68
|
+
function AdminPermissionsProvider({ api, children }) {
|
|
69
|
+
const matrixState = useResource(React.useCallback((signal) => api.permissions.get(signal), [api]), [api]);
|
|
70
|
+
const groupsApi = api.groups;
|
|
71
|
+
const loadGroups = React.useCallback(async (signal) => {
|
|
72
|
+
const [rows, roles] = await Promise.all([groupsApi.list(signal), groupsApi.roles(signal)]);
|
|
73
|
+
return {
|
|
74
|
+
rows,
|
|
75
|
+
roles
|
|
76
|
+
};
|
|
77
|
+
}, [groupsApi]);
|
|
78
|
+
const groupsState = useResource(groupsApi ? loadGroups : void 0, [loadGroups, Boolean(groupsApi)]);
|
|
79
|
+
const [revision, setRevision] = React.useState(0);
|
|
80
|
+
/** Optimistic layers. Both are keyed by principal id and win over the server's copy. */
|
|
81
|
+
const [memberRoles, setMemberRoles] = React.useState(() => /* @__PURE__ */ new Map());
|
|
82
|
+
const [groupOverrides, setGroupOverrides] = React.useState(() => /* @__PURE__ */ new Map());
|
|
83
|
+
const [matrixOverride, setMatrixOverride] = React.useState(null);
|
|
84
|
+
/**
|
|
85
|
+
* Principals whose write the server has ACCEPTED.
|
|
86
|
+
*
|
|
87
|
+
* The optimistic entry has two jobs and they end at different moments: it
|
|
88
|
+
* covers the round trip (until the call resolves), and it covers the gap
|
|
89
|
+
* before the list refetches (until a response mentions the principal). Once
|
|
90
|
+
* both are done the entry is not optimism, it is a permanent local truth —
|
|
91
|
+
* and a second administrator's change to the same principal would never
|
|
92
|
+
* appear, however many times the page refetched.
|
|
93
|
+
*/
|
|
94
|
+
const settled = React.useRef({
|
|
95
|
+
members: /* @__PURE__ */ new Set(),
|
|
96
|
+
groups: /* @__PURE__ */ new Set()
|
|
97
|
+
});
|
|
98
|
+
const serverGroupRoles = groupsState.data?.roles;
|
|
99
|
+
const groupRoles = React.useMemo(() => {
|
|
100
|
+
if (!serverGroupRoles && groupOverrides.size === 0) return NO_GROUP_ROLES;
|
|
101
|
+
const merged = { ...serverGroupRoles ?? {} };
|
|
102
|
+
for (const [id, roles] of groupOverrides) merged[id] = roles;
|
|
103
|
+
return merged;
|
|
104
|
+
}, [serverGroupRoles, groupOverrides]);
|
|
105
|
+
const assignRoles = React.useCallback(async (principal, roles) => {
|
|
106
|
+
const next = [...roles];
|
|
107
|
+
if (principal.kind === "user") {
|
|
108
|
+
const previous = memberRoles.get(principal.id);
|
|
109
|
+
setMemberRoles((prev) => new Map(prev).set(principal.id, next));
|
|
110
|
+
try {
|
|
111
|
+
await api.members.setRoles(principal.id, next);
|
|
112
|
+
settled.current.members.add(principal.id);
|
|
113
|
+
} catch (error) {
|
|
114
|
+
setMemberRoles((prev) => {
|
|
115
|
+
const back = new Map(prev);
|
|
116
|
+
if (previous === void 0) back.delete(principal.id);
|
|
117
|
+
else back.set(principal.id, previous);
|
|
118
|
+
return back;
|
|
119
|
+
});
|
|
120
|
+
throw error;
|
|
121
|
+
}
|
|
122
|
+
} else {
|
|
123
|
+
if (!api.groups) throw new Error("This API has no groups");
|
|
124
|
+
const previous = groupOverrides.get(principal.id);
|
|
125
|
+
setGroupOverrides((prev) => new Map(prev).set(principal.id, next));
|
|
126
|
+
try {
|
|
127
|
+
await api.groups.setRoles(principal.id, next);
|
|
128
|
+
settled.current.groups.add(principal.id);
|
|
129
|
+
} catch (error) {
|
|
130
|
+
setGroupOverrides((prev) => {
|
|
131
|
+
const back = new Map(prev);
|
|
132
|
+
if (previous === void 0) back.delete(principal.id);
|
|
133
|
+
else back.set(principal.id, previous);
|
|
134
|
+
return back;
|
|
135
|
+
});
|
|
136
|
+
throw error;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
setRevision((r) => r + 1);
|
|
140
|
+
if (principal.kind === "group") groupsState.reload();
|
|
141
|
+
}, [
|
|
142
|
+
api,
|
|
143
|
+
memberRoles,
|
|
144
|
+
groupOverrides,
|
|
145
|
+
groupsState
|
|
146
|
+
]);
|
|
147
|
+
/**
|
|
148
|
+
* Drop a member override the server has caught up with.
|
|
149
|
+
*
|
|
150
|
+
* The optimistic entry exists to cover the gap between a PATCH and the next
|
|
151
|
+
* list; once the list agrees, keeping it is not optimism but a permanent
|
|
152
|
+
* local truth. A second administrator's change to the same member would
|
|
153
|
+
* then never appear, however many times the page refetched — the row would
|
|
154
|
+
* go on showing what THIS browser last wrote, for the life of the tab.
|
|
155
|
+
*/
|
|
156
|
+
const reconcileMembers = React.useCallback((rows) => {
|
|
157
|
+
setMemberRoles((prev) => {
|
|
158
|
+
if (prev.size === 0) return prev;
|
|
159
|
+
let next = null;
|
|
160
|
+
for (const row of rows) {
|
|
161
|
+
const pending = prev.get(row.userId);
|
|
162
|
+
if (!pending) continue;
|
|
163
|
+
if (sameRoles(pending, row.extensionRoles ?? []) || settled.current.members.has(row.userId)) {
|
|
164
|
+
settled.current.members.delete(row.userId);
|
|
165
|
+
next ??= new Map(prev);
|
|
166
|
+
next.delete(row.userId);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return next ?? prev;
|
|
170
|
+
});
|
|
171
|
+
}, []);
|
|
172
|
+
React.useEffect(() => {
|
|
173
|
+
if (!serverGroupRoles) return;
|
|
174
|
+
setGroupOverrides((prev) => {
|
|
175
|
+
if (prev.size === 0) return prev;
|
|
176
|
+
let next = null;
|
|
177
|
+
for (const [id, roles] of prev) if (sameRoles(roles, serverGroupRoles[id] ?? []) || settled.current.groups.has(id)) {
|
|
178
|
+
settled.current.groups.delete(id);
|
|
179
|
+
next ??= new Map(prev);
|
|
180
|
+
next.delete(id);
|
|
181
|
+
}
|
|
182
|
+
return next ?? prev;
|
|
183
|
+
});
|
|
184
|
+
}, [serverGroupRoles]);
|
|
185
|
+
const saveMatrix = React.useCallback(async (next) => {
|
|
186
|
+
const previous = matrixOverride;
|
|
187
|
+
setMatrixOverride(next);
|
|
188
|
+
try {
|
|
189
|
+
const saved = await api.permissions.put(next);
|
|
190
|
+
if (saved) setMatrixOverride(saved);
|
|
191
|
+
} catch (error) {
|
|
192
|
+
setMatrixOverride(previous);
|
|
193
|
+
throw error;
|
|
194
|
+
}
|
|
195
|
+
setRevision((r) => r + 1);
|
|
196
|
+
}, [api, matrixOverride]);
|
|
197
|
+
const matrix = matrixOverride ?? matrixState.data;
|
|
198
|
+
const groups = groupsState.data?.rows ?? [];
|
|
199
|
+
const value = React.useMemo(() => ({
|
|
200
|
+
api,
|
|
201
|
+
matrix,
|
|
202
|
+
matrixState,
|
|
203
|
+
saveMatrix,
|
|
204
|
+
hasGroups: Boolean(api.groups) && groups.length > 0,
|
|
205
|
+
groupsUnknown: Boolean(api.groups) && (groupsState.loading || groupsState.error !== null),
|
|
206
|
+
groups,
|
|
207
|
+
groupsState,
|
|
208
|
+
groupRoles,
|
|
209
|
+
revision,
|
|
210
|
+
memberRoles,
|
|
211
|
+
assignRoles,
|
|
212
|
+
reconcileMembers
|
|
213
|
+
}), [
|
|
214
|
+
api,
|
|
215
|
+
matrix,
|
|
216
|
+
matrixState,
|
|
217
|
+
saveMatrix,
|
|
218
|
+
groupsState,
|
|
219
|
+
groupRoles,
|
|
220
|
+
revision,
|
|
221
|
+
memberRoles,
|
|
222
|
+
assignRoles,
|
|
223
|
+
reconcileMembers
|
|
224
|
+
]);
|
|
225
|
+
return /* @__PURE__ */ jsx(AdminPermissionsContext, {
|
|
226
|
+
value,
|
|
227
|
+
children
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* The direct roles to show for a member: what the server sent, unless this
|
|
232
|
+
* screen has since written something else. A PATCH is not visible in the list
|
|
233
|
+
* until the source refetches, and on the local (no `QueryClientProvider`)
|
|
234
|
+
* strategy that may be a page change away — so the write is remembered rather
|
|
235
|
+
* than the row lying about it.
|
|
236
|
+
*/
|
|
237
|
+
function directRolesOf(row, memberRoles) {
|
|
238
|
+
return memberRoles.get(row.userId) ?? row.extensionRoles ?? [];
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* The effective roles to show for a member after a local write. Inherited
|
|
242
|
+
* entries stay the server's — this screen does not know who belongs to which
|
|
243
|
+
* group — and the direct ones are replaced by whatever was just assigned.
|
|
244
|
+
*/
|
|
245
|
+
function effectiveRolesWithOverride(row, memberRoles) {
|
|
246
|
+
const override = memberRoles.get(row.userId);
|
|
247
|
+
const server = effectiveRolesOf(row);
|
|
248
|
+
if (!override) return server;
|
|
249
|
+
const inherited = server.filter((entry) => entry.source !== "direct");
|
|
250
|
+
return [...override.map((role) => ({
|
|
251
|
+
role,
|
|
252
|
+
source: "direct"
|
|
253
|
+
})), ...inherited];
|
|
254
|
+
}
|
|
255
|
+
//#endregion
|
|
256
|
+
export { AdminPermissionsProvider, directRolesOf, effectiveRolesWithOverride, useAdminPermissions, useResource };
|
|
257
|
+
|
|
258
|
+
//# sourceMappingURL=context.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"context.js","names":[],"sources":["../../../src/components/admin-permissions/context.tsx"],"sourcesContent":["\"use client\";\n\nimport * as React from \"react\";\nimport {\n adminErrorMessage,\n effectiveRolesOf,\n type AdminEffectiveRole,\n type AdminGroupRow,\n type AdminMemberRow,\n type AdminPermissionMatrix,\n type AdminPermissionsApi,\n type AdminPrincipal,\n} from \"./types\";\n\n/**\n * One async read, with the abort a screen needs when it unmounts mid-flight.\n * Small on purpose: the composition fetches three documents of its own (the\n * matrix, the licence, the groups) and the members list is a DataTable on the\n * server source, which owns its own fetching.\n */\nexport interface Resource<T> {\n data: T | null;\n error: Error | null;\n loading: boolean;\n reload: () => void;\n}\n\nexport function useResource<T>(\n load: ((signal: AbortSignal) => Promise<T>) | undefined,\n deps: React.DependencyList,\n): Resource<T> {\n const [state, setState] = React.useState<{\n data: T | null;\n error: Error | null;\n loading: boolean;\n }>({ data: null, error: null, loading: Boolean(load) });\n const [tick, setTick] = React.useState(0);\n // The consumer's `api` is usually an object literal, so its methods are new\n // on every parent render. Reading the latest through a ref keeps a render\n // from cancelling and refiring the request it just made.\n const loadRef = React.useRef(load);\n React.useEffect(() => {\n loadRef.current = load;\n });\n\n React.useEffect(() => {\n const run = loadRef.current;\n if (!run) {\n setState({ data: null, error: null, loading: false });\n return;\n }\n const controller = new AbortController();\n setState((s) => ({ ...s, loading: true }));\n run(controller.signal).then(\n (data) => {\n if (!controller.signal.aborted) setState({ data, error: null, loading: false });\n },\n (err: unknown) => {\n if (controller.signal.aborted) return;\n // `adminErrorMessage` unconditionally, not only for a non-Error: the\n // broker's envelope hangs off an `Error(\"HTTP 403\")`, and taking the\n // Error's own message showed the status where §15.6 asks for the\n // server's sentence.\n setState({ data: null, error: new Error(adminErrorMessage(err)), loading: false });\n },\n );\n return () => controller.abort();\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [...deps, tick]);\n\n return { ...state, reload: React.useCallback(() => setTick((t) => t + 1), []) };\n}\n\n/** `user:<id>` / `group:<id>` — the key an optimistic write is filed under. */\nexport function principalKey(principal: AdminPrincipal): string {\n return `${principal.kind}:${principal.id}`;\n}\n\nexport interface AdminPermissionsContextValue {\n api: AdminPermissionsApi;\n /** The declared vocabulary and matrix. `null` until it loads, or when it failed. */\n matrix: AdminPermissionMatrix | null;\n matrixState: Resource<AdminPermissionMatrix>;\n /** Write the matrix. Optimistic: the grid shows the new grants before the PUT lands. */\n saveMatrix: (next: AdminPermissionMatrix) => Promise<void>;\n /**\n * True when the API offers groups *and* the org actually has some. Every\n * group affordance is hidden otherwise.\n */\n hasGroups: boolean;\n /**\n * True while the answer to `hasGroups` is not known yet — the list is in\n * flight, or it failed.\n *\n * `hasGroups === false` used to mean three different things at once: this\n * API has no groups, this org has none, and `groups.list()` blew up. The\n * third renders identically to the second, so a member who inherits\n * \"reviewer\" from Legal is shown with no roles at all and an administrator\n * reads that as a fact about the member. A failure has to look like a\n * failure.\n */\n groupsUnknown: boolean;\n groups: AdminGroupRow[];\n groupsState: Resource<{ rows: AdminGroupRow[]; roles: Record<string, string[]> }>;\n /** group id → the roles it carries, including any not yet confirmed by the server. */\n groupRoles: Readonly<Record<string, string[]>>;\n /** Bumped after a successful assignment, so the members source refetches. */\n revision: number;\n /** Direct member roles written since the last refetch, by user id. */\n memberRoles: ReadonlyMap<string, string[]>;\n /**\n * Assign a principal's direct roles. Optimistic: the screen shows the new\n * roles immediately and puts the old ones back if the call rejects, then\n * rethrows so the caller can show the envelope's message.\n */\n assignRoles: (principal: AdminPrincipal, roles: string[]) => Promise<void>;\n /**\n * Hand back rows the server has just answered with, so any optimistic entry\n * the server now agrees with can be dropped. See `memberRoles`.\n */\n reconcileMembers: (rows: readonly AdminMemberRow[]) => void;\n}\n\n/** Same roles, order-insensitively — which is all an assignment is. */\nfunction sameRoles(a: readonly string[], b: readonly string[]): boolean {\n if (a.length !== b.length) return false;\n const left = [...a].sort();\n const right = [...b].sort();\n return left.every((role, i) => role === right[i]);\n}\n\nconst AdminPermissionsContext =\n /* @__PURE__ */ React.createContext<AdminPermissionsContextValue | null>(null);\n\nexport function useAdminPermissions(): AdminPermissionsContextValue {\n const value = React.useContext(AdminPermissionsContext);\n if (!value) {\n throw new Error(\"AdminPermissions parts must be rendered inside <AdminPermissions>\");\n }\n return value;\n}\n\nconst NO_GROUP_ROLES: Readonly<Record<string, string[]>> = /* @__PURE__ */ Object.freeze({});\n\nexport function AdminPermissionsProvider({\n api,\n children,\n}: {\n api: AdminPermissionsApi;\n children: React.ReactNode;\n}) {\n const matrixState = useResource<AdminPermissionMatrix>(\n React.useCallback((signal: AbortSignal) => api.permissions.get(signal), [api]),\n [api],\n );\n\n const groupsApi = api.groups;\n const loadGroups = React.useCallback(\n async (signal: AbortSignal) => {\n const [rows, roles] = await Promise.all([groupsApi!.list(signal), groupsApi!.roles(signal)]);\n return { rows, roles };\n },\n [groupsApi],\n );\n const groupsState = useResource(groupsApi ? loadGroups : undefined, [loadGroups, Boolean(groupsApi)]);\n\n const [revision, setRevision] = React.useState(0);\n /** Optimistic layers. Both are keyed by principal id and win over the server's copy. */\n const [memberRoles, setMemberRoles] = React.useState<ReadonlyMap<string, string[]>>(\n () => new Map(),\n );\n const [groupOverrides, setGroupOverrides] = React.useState<ReadonlyMap<string, string[]>>(\n () => new Map(),\n );\n const [matrixOverride, setMatrixOverride] = React.useState<AdminPermissionMatrix | null>(null);\n /**\n * Principals whose write the server has ACCEPTED.\n *\n * The optimistic entry has two jobs and they end at different moments: it\n * covers the round trip (until the call resolves), and it covers the gap\n * before the list refetches (until a response mentions the principal). Once\n * both are done the entry is not optimism, it is a permanent local truth —\n * and a second administrator's change to the same principal would never\n * appear, however many times the page refetched.\n */\n const settled = React.useRef({ members: new Set<string>(), groups: new Set<string>() });\n\n const serverGroupRoles = groupsState.data?.roles;\n const groupRoles = React.useMemo(() => {\n if (!serverGroupRoles && groupOverrides.size === 0) return NO_GROUP_ROLES;\n const merged: Record<string, string[]> = { ...(serverGroupRoles ?? {}) };\n for (const [id, roles] of groupOverrides) merged[id] = roles;\n return merged;\n }, [serverGroupRoles, groupOverrides]);\n\n const assignRoles = React.useCallback(\n async (principal: AdminPrincipal, roles: string[]) => {\n const next = [...roles];\n if (principal.kind === \"user\") {\n // Optimistic, then rolled back to exactly what was there before —\n // absent, if this member had never been written from this screen.\n const previous = memberRoles.get(principal.id);\n setMemberRoles((prev) => new Map(prev).set(principal.id, next));\n try {\n await api.members.setRoles(principal.id, next);\n settled.current.members.add(principal.id);\n } catch (error) {\n setMemberRoles((prev) => {\n const back = new Map(prev);\n if (previous === undefined) back.delete(principal.id);\n else back.set(principal.id, previous);\n return back;\n });\n throw error;\n }\n } else {\n if (!api.groups) throw new Error(\"This API has no groups\");\n const previous = groupOverrides.get(principal.id);\n setGroupOverrides((prev) => new Map(prev).set(principal.id, next));\n try {\n await api.groups.setRoles(principal.id, next);\n settled.current.groups.add(principal.id);\n } catch (error) {\n setGroupOverrides((prev) => {\n const back = new Map(prev);\n if (previous === undefined) back.delete(principal.id);\n else back.set(principal.id, previous);\n return back;\n });\n throw error;\n }\n }\n // A group's roles change what every one of its members inherits, so the\n // members page is refetched rather than left showing a stale resolution.\n setRevision((r) => r + 1);\n if (principal.kind === \"group\") {\n // And the groups document is re-read, which is what retires the\n // optimistic layer for this group: an override that outlives the\n // server's agreement is a row that can no longer be corrected by\n // anyone else.\n groupsState.reload();\n }\n },\n [api, memberRoles, groupOverrides, groupsState],\n );\n\n /**\n * Drop a member override the server has caught up with.\n *\n * The optimistic entry exists to cover the gap between a PATCH and the next\n * list; once the list agrees, keeping it is not optimism but a permanent\n * local truth. A second administrator's change to the same member would\n * then never appear, however many times the page refetched — the row would\n * go on showing what THIS browser last wrote, for the life of the tab.\n */\n const reconcileMembers = React.useCallback((rows: readonly AdminMemberRow[]) => {\n setMemberRoles((prev) => {\n if (prev.size === 0) return prev;\n let next: Map<string, string[]> | null = null;\n for (const row of rows) {\n const pending = prev.get(row.userId);\n if (!pending) continue;\n // Either the server already agrees, or the write it acknowledged has\n // now been through a list — in which case this row IS the truth, even\n // when it says something else, because somebody else changed it.\n if (sameRoles(pending, row.extensionRoles ?? []) || settled.current.members.has(row.userId)) {\n settled.current.members.delete(row.userId);\n next ??= new Map(prev);\n next.delete(row.userId);\n }\n }\n return next ?? prev;\n });\n }, []);\n\n // The same rule for groups: an override the server now echoes is retired.\n React.useEffect(() => {\n if (!serverGroupRoles) return;\n setGroupOverrides((prev) => {\n if (prev.size === 0) return prev;\n let next: Map<string, string[]> | null = null;\n for (const [id, roles] of prev) {\n if (sameRoles(roles, serverGroupRoles[id] ?? []) || settled.current.groups.has(id)) {\n settled.current.groups.delete(id);\n next ??= new Map(prev);\n next.delete(id);\n }\n }\n return next ?? prev;\n });\n }, [serverGroupRoles]);\n\n const saveMatrix = React.useCallback(\n async (next: AdminPermissionMatrix) => {\n const previous = matrixOverride;\n setMatrixOverride(next);\n try {\n const saved = await api.permissions.put(next);\n if (saved) setMatrixOverride(saved);\n } catch (error) {\n setMatrixOverride(previous);\n throw error;\n }\n setRevision((r) => r + 1);\n },\n [api, matrixOverride],\n );\n\n const matrix = matrixOverride ?? matrixState.data;\n const groups = groupsState.data?.rows ?? [];\n\n const value = React.useMemo<AdminPermissionsContextValue>(\n () => ({\n api,\n matrix,\n matrixState,\n saveMatrix,\n hasGroups: Boolean(api.groups) && groups.length > 0,\n groupsUnknown: Boolean(api.groups) && (groupsState.loading || groupsState.error !== null),\n groups,\n groupsState,\n groupRoles,\n revision,\n memberRoles,\n assignRoles,\n reconcileMembers,\n }),\n // `groups` is derived from `groupsState`, which is in the list.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n [\n api,\n matrix,\n matrixState,\n saveMatrix,\n groupsState,\n groupRoles,\n revision,\n memberRoles,\n assignRoles,\n reconcileMembers,\n ],\n );\n\n return <AdminPermissionsContext value={value}>{children}</AdminPermissionsContext>;\n}\n\n/**\n * The direct roles to show for a member: what the server sent, unless this\n * screen has since written something else. A PATCH is not visible in the list\n * until the source refetches, and on the local (no `QueryClientProvider`)\n * strategy that may be a page change away — so the write is remembered rather\n * than the row lying about it.\n */\nexport function directRolesOf(\n row: AdminMemberRow,\n memberRoles: ReadonlyMap<string, string[]>,\n): string[] {\n return memberRoles.get(row.userId) ?? row.extensionRoles ?? [];\n}\n\n/**\n * The effective roles to show for a member after a local write. Inherited\n * entries stay the server's — this screen does not know who belongs to which\n * group — and the direct ones are replaced by whatever was just assigned.\n */\nexport function effectiveRolesWithOverride(\n row: AdminMemberRow,\n memberRoles: ReadonlyMap<string, string[]>,\n): AdminEffectiveRole[] {\n const override = memberRoles.get(row.userId);\n const server = effectiveRolesOf(row);\n if (!override) return server;\n const inherited = server.filter((entry) => entry.source !== \"direct\");\n const direct = override.map((role) => ({ role, source: \"direct\" as const }));\n return [...direct, ...inherited];\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { ADMIN_ROUTES, ADMIN_ROUTE_LABELS, AdminEffectiveRole, AdminErrorEnvelope, AdminGroupRef, AdminGroupRow, AdminLicence, AdminMemberRow, AdminPermissionMatrix, AdminPermissionsApi, AdminPrincipal, AdminRoleSource, AdminRoute, adminErrorMessage, effectiveRolesOf, sourceGroupOf } from "./types.js";
|
|
3
|
+
import { AdminPermissions, AdminPermissionsProps } from "./admin-permissions.js";
|
|
4
|
+
import { AdminMembers } from "./members.js";
|
|
5
|
+
import { AdminRoles } from "./roles.js";
|
|
6
|
+
import { AdminLicenceScreen } from "./licence.js";
|
|
7
|
+
import { AdminMatrix } from "./matrix.js";
|
|
8
|
+
import { DirectRoles, EffectiveRoles, RoleAssignmentDialog, RoleAssignmentDialogProps } from "./role-assignment.js";
|
|
9
|
+
import { AdminPermissionsContextValue, AdminPermissionsProvider, useAdminPermissions } from "./context.js";
|
|
10
|
+
export { ADMIN_ROUTES, ADMIN_ROUTE_LABELS, type AdminEffectiveRole, type AdminErrorEnvelope, type AdminGroupRef, type AdminGroupRow, type AdminLicence, AdminLicenceScreen, AdminMatrix, type AdminMemberRow, AdminMembers, type AdminPermissionMatrix, AdminPermissions, type AdminPermissionsApi, type AdminPermissionsContextValue, type AdminPermissionsProps, AdminPermissionsProvider, type AdminPrincipal, type AdminRoleSource, AdminRoles, type AdminRoute, DirectRoles, EffectiveRoles, RoleAssignmentDialog, type RoleAssignmentDialogProps, adminErrorMessage, effectiveRolesOf, sourceGroupOf, useAdminPermissions };
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
//#region src/components/admin-permissions/licence.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* `/admin/licence` — a read-through display of cortena-auth's licence, and
|
|
6
|
+
* nothing else.
|
|
7
|
+
*
|
|
8
|
+
* There is no edit control on this screen by design: the licence is not the
|
|
9
|
+
* extension's to change, and a disabled field would suggest it might be. What
|
|
10
|
+
* the administrator needs from here is the answer to "have we run out of
|
|
11
|
+
* seats", which is why seats are a bar rather than two numbers in a sentence.
|
|
12
|
+
*/
|
|
13
|
+
export declare function AdminLicenceScreen(): React.JSX.Element | null;
|
|
14
|
+
//#endregion
|
|
15
|
+
//# sourceMappingURL=licence.d.ts.map
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
"use client";
|
|
3
|
+
import { Badge } from "../badge.js";
|
|
4
|
+
import { Chip } from "../chip.js";
|
|
5
|
+
import { ErrorBanner } from "../error-banner.js";
|
|
6
|
+
import { LoadingSkeleton } from "../loading-skeleton.js";
|
|
7
|
+
import { Progress, ProgressLabel, ProgressValue } from "../progress.js";
|
|
8
|
+
import { SectionCard } from "../section-card.js";
|
|
9
|
+
import { useAdminPermissions, useResource } from "./context.js";
|
|
10
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
11
|
+
import * as React from "react";
|
|
12
|
+
//#region src/components/admin-permissions/licence.tsx
|
|
13
|
+
/**
|
|
14
|
+
* `/admin/licence` — a read-through display of cortena-auth's licence, and
|
|
15
|
+
* nothing else.
|
|
16
|
+
*
|
|
17
|
+
* There is no edit control on this screen by design: the licence is not the
|
|
18
|
+
* extension's to change, and a disabled field would suggest it might be. What
|
|
19
|
+
* the administrator needs from here is the answer to "have we run out of
|
|
20
|
+
* seats", which is why seats are a bar rather than two numbers in a sentence.
|
|
21
|
+
*/
|
|
22
|
+
function AdminLicenceScreen() {
|
|
23
|
+
const { api } = useAdminPermissions();
|
|
24
|
+
const load = React.useCallback((signal) => api.licence.get(signal), [api]);
|
|
25
|
+
const { data, error, loading, reload } = useResource(load, [load]);
|
|
26
|
+
if (error) return /* @__PURE__ */ jsx(ErrorBanner, {
|
|
27
|
+
message: error.message,
|
|
28
|
+
onRetry: reload
|
|
29
|
+
});
|
|
30
|
+
if (loading && !data) return /* @__PURE__ */ jsx(LoadingSkeleton, {
|
|
31
|
+
preset: "card",
|
|
32
|
+
"data-slot": "admin-licence-loading"
|
|
33
|
+
});
|
|
34
|
+
if (!data) return null;
|
|
35
|
+
const seats = typeof data.seats === "number" ? data.seats : null;
|
|
36
|
+
const used = typeof data.seatsUsed === "number" ? data.seatsUsed : null;
|
|
37
|
+
const features = data.features ?? [];
|
|
38
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
39
|
+
"data-slot": "admin-licence",
|
|
40
|
+
className: "flex flex-col gap-6",
|
|
41
|
+
children: [/* @__PURE__ */ jsx(SectionCard, {
|
|
42
|
+
title: "Licence",
|
|
43
|
+
description: "Held by cortena-auth for this organisation. It is shown here and changed there.",
|
|
44
|
+
actions: /* @__PURE__ */ jsx(Badge, {
|
|
45
|
+
variant: "info",
|
|
46
|
+
"data-slot": "admin-licence-plan",
|
|
47
|
+
children: data.plan
|
|
48
|
+
}),
|
|
49
|
+
children: seats === null ? /* @__PURE__ */ jsx("p", {
|
|
50
|
+
className: "text-[length:var(--ds-text-body-sm)] text-[color:var(--ds-muted-foreground)]",
|
|
51
|
+
children: "This plan does not meter seats."
|
|
52
|
+
}) : /* @__PURE__ */ jsxs(Progress, {
|
|
53
|
+
value: used ?? 0,
|
|
54
|
+
max: seats,
|
|
55
|
+
"data-slot": "admin-licence-seats",
|
|
56
|
+
children: [/* @__PURE__ */ jsx(ProgressLabel, { children: "Seats used" }), /* @__PURE__ */ jsx(ProgressValue, { children: () => `${used ?? 0} of ${seats}` })]
|
|
57
|
+
})
|
|
58
|
+
}), /* @__PURE__ */ jsx(SectionCard, {
|
|
59
|
+
title: "Features",
|
|
60
|
+
description: "What this licence entitles the organisation to. A feature is not a permission: it says the org may use something, not that this user may.",
|
|
61
|
+
children: features.length === 0 ? /* @__PURE__ */ jsx("p", {
|
|
62
|
+
className: "text-[length:var(--ds-text-body-sm)] text-[color:var(--ds-muted-foreground)]",
|
|
63
|
+
children: "No features listed on this licence."
|
|
64
|
+
}) : /* @__PURE__ */ jsx("div", {
|
|
65
|
+
className: "flex flex-wrap items-center gap-1.5",
|
|
66
|
+
"data-slot": "admin-licence-features",
|
|
67
|
+
children: features.map((feature) => /* @__PURE__ */ jsx(Chip, {
|
|
68
|
+
"data-feature": feature,
|
|
69
|
+
children: feature
|
|
70
|
+
}, feature))
|
|
71
|
+
})
|
|
72
|
+
})]
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
//#endregion
|
|
76
|
+
export { AdminLicenceScreen };
|
|
77
|
+
|
|
78
|
+
//# sourceMappingURL=licence.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"licence.js","names":[],"sources":["../../../src/components/admin-permissions/licence.tsx"],"sourcesContent":["\"use client\";\n\nimport * as React from \"react\";\nimport { Badge } from \"@/components/badge\";\nimport { Chip } from \"@/components/chip\";\nimport { ErrorBanner } from \"@/components/error-banner\";\nimport { LoadingSkeleton } from \"@/components/loading-skeleton\";\nimport { Progress, ProgressLabel, ProgressValue } from \"@/components/progress\";\nimport { SectionCard } from \"@/components/section-card\";\nimport { useAdminPermissions, useResource } from \"./context\";\nimport type { AdminLicence } from \"./types\";\n\n/**\n * `/admin/licence` — a read-through display of cortena-auth's licence, and\n * nothing else.\n *\n * There is no edit control on this screen by design: the licence is not the\n * extension's to change, and a disabled field would suggest it might be. What\n * the administrator needs from here is the answer to \"have we run out of\n * seats\", which is why seats are a bar rather than two numbers in a sentence.\n */\nexport function AdminLicenceScreen() {\n const { api } = useAdminPermissions();\n const load = React.useCallback((signal: AbortSignal) => api.licence.get(signal), [api]);\n const { data, error, loading, reload } = useResource<AdminLicence>(load, [load]);\n\n if (error) {\n return <ErrorBanner message={error.message} onRetry={reload} />;\n }\n if (loading && !data) {\n return <LoadingSkeleton preset=\"card\" data-slot=\"admin-licence-loading\" />;\n }\n if (!data) return null;\n\n const seats = typeof data.seats === \"number\" ? data.seats : null;\n const used = typeof data.seatsUsed === \"number\" ? data.seatsUsed : null;\n const features = data.features ?? [];\n\n return (\n <div data-slot=\"admin-licence\" className=\"flex flex-col gap-6\">\n <SectionCard\n title=\"Licence\"\n description=\"Held by cortena-auth for this organisation. It is shown here and changed there.\"\n actions={\n <Badge variant=\"info\" data-slot=\"admin-licence-plan\">\n {data.plan}\n </Badge>\n }\n >\n {seats === null ? (\n <p className=\"text-[length:var(--ds-text-body-sm)] text-[color:var(--ds-muted-foreground)]\">\n This plan does not meter seats.\n </p>\n ) : (\n <Progress value={used ?? 0} max={seats} data-slot=\"admin-licence-seats\">\n <ProgressLabel>Seats used</ProgressLabel>\n {/* Base UI hands the formatted value to a render function; the raw\n count against the cap is what an administrator is looking for. */}\n <ProgressValue>{() => `${used ?? 0} of ${seats}`}</ProgressValue>\n </Progress>\n )}\n </SectionCard>\n\n <SectionCard\n title=\"Features\"\n description=\"What this licence entitles the organisation to. A feature is not a permission: it says the org may use something, not that this user may.\"\n >\n {features.length === 0 ? (\n <p className=\"text-[length:var(--ds-text-body-sm)] text-[color:var(--ds-muted-foreground)]\">\n No features listed on this licence.\n </p>\n ) : (\n <div className=\"flex flex-wrap items-center gap-1.5\" data-slot=\"admin-licence-features\">\n {features.map((feature) => (\n <Chip key={feature} data-feature={feature}>\n {feature}\n </Chip>\n ))}\n </div>\n )}\n </SectionCard>\n </div>\n );\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;AAqBA,SAAgB,qBAAqB;CACnC,MAAM,EAAE,QAAQ,oBAAoB;CACpC,MAAM,OAAO,MAAM,aAAa,WAAwB,IAAI,QAAQ,IAAI,MAAM,GAAG,CAAC,GAAG,CAAC;CACtF,MAAM,EAAE,MAAM,OAAO,SAAS,WAAW,YAA0B,MAAM,CAAC,IAAI,CAAC;CAE/E,IAAI,OACF,OAAO,oBAAC,aAAD;EAAa,SAAS,MAAM;EAAS,SAAS;CAAS,CAAA;CAEhE,IAAI,WAAW,CAAC,MACd,OAAO,oBAAC,iBAAD;EAAiB,QAAO;EAAO,aAAU;CAAyB,CAAA;CAE3E,IAAI,CAAC,MAAM,OAAO;CAElB,MAAM,QAAQ,OAAO,KAAK,UAAU,WAAW,KAAK,QAAQ;CAC5D,MAAM,OAAO,OAAO,KAAK,cAAc,WAAW,KAAK,YAAY;CACnE,MAAM,WAAW,KAAK,YAAY,CAAC;CAEnC,OACE,qBAAC,OAAD;EAAK,aAAU;EAAgB,WAAU;EAAzC,UAAA,CACE,oBAAC,aAAD;GACE,OAAM;GACN,aAAY;GACZ,SACE,oBAAC,OAAD;IAAO,SAAQ;IAAO,aAAU;IAC7B,UAAA,KAAK;GACD,CAAA;GAGR,UAAA,UAAU,OACT,oBAAC,KAAD;IAAG,WAAU;IAA+E,UAAA;GAEzF,CAAA,IAEH,qBAAC,UAAD;IAAU,OAAO,QAAQ;IAAG,KAAK;IAAO,aAAU;IAAlD,UAAA,CACE,oBAAC,eAAD,EAAA,UAAe,aAAyB,CAAA,GAGxC,oBAAC,eAAD,EAAA,gBAAsB,GAAG,QAAQ,EAAE,MAAM,QAAuB,CAAA,CACxD;;EAED,CAAA,GAEb,oBAAC,aAAD;GACE,OAAM;GACN,aAAY;GAEX,UAAA,SAAS,WAAW,IACnB,oBAAC,KAAD;IAAG,WAAU;IAA+E,UAAA;GAEzF,CAAA,IAEH,oBAAC,OAAD;IAAK,WAAU;IAAsC,aAAU;IAC5D,UAAA,SAAS,KAAK,YACb,oBAAC,MAAD;KAAoB,gBAAc;KAC/B,UAAA;IACG,GAFK,OAEL,CACP;GACE,CAAA;EAEI,CAAA,CACV;;AAET"}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
//#region src/components/admin-permissions/matrix.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* `/admin/permissions` — the extension's own roles × actions matrix, the one
|
|
6
|
+
* document `may()` reads (§13.2, P-14).
|
|
7
|
+
*
|
|
8
|
+
* A grid of checkboxes, and nothing clever: rows are the roles the extension
|
|
9
|
+
* declares, columns are the actions it declares, and a tick is a grant. The
|
|
10
|
+
* screen holds a draft rather than writing per click — a permission matrix is
|
|
11
|
+
* edited as a whole thought, and a half-applied one is a security hole with a
|
|
12
|
+
* progress spinner over it — so the edits accumulate, Save PUTs the document
|
|
13
|
+
* and Discard throws the draft away.
|
|
14
|
+
*
|
|
15
|
+
* The roles and the actions are both read-only here: they are the extension's
|
|
16
|
+
* declared vocabulary, changed in its source, not in an admin screen.
|
|
17
|
+
*/
|
|
18
|
+
export declare function AdminMatrix(): React.JSX.Element;
|
|
19
|
+
//#endregion
|
|
20
|
+
//# sourceMappingURL=matrix.d.ts.map
|