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,376 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import {
|
|
5
|
+
adminErrorMessage,
|
|
6
|
+
effectiveRolesOf,
|
|
7
|
+
type AdminEffectiveRole,
|
|
8
|
+
type AdminGroupRow,
|
|
9
|
+
type AdminMemberRow,
|
|
10
|
+
type AdminPermissionMatrix,
|
|
11
|
+
type AdminPermissionsApi,
|
|
12
|
+
type AdminPrincipal,
|
|
13
|
+
} from "./types";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* One async read, with the abort a screen needs when it unmounts mid-flight.
|
|
17
|
+
* Small on purpose: the composition fetches three documents of its own (the
|
|
18
|
+
* matrix, the licence, the groups) and the members list is a DataTable on the
|
|
19
|
+
* server source, which owns its own fetching.
|
|
20
|
+
*/
|
|
21
|
+
export interface Resource<T> {
|
|
22
|
+
data: T | null;
|
|
23
|
+
error: Error | null;
|
|
24
|
+
loading: boolean;
|
|
25
|
+
reload: () => void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function useResource<T>(
|
|
29
|
+
load: ((signal: AbortSignal) => Promise<T>) | undefined,
|
|
30
|
+
deps: React.DependencyList,
|
|
31
|
+
): Resource<T> {
|
|
32
|
+
const [state, setState] = React.useState<{
|
|
33
|
+
data: T | null;
|
|
34
|
+
error: Error | null;
|
|
35
|
+
loading: boolean;
|
|
36
|
+
}>({ data: null, error: null, loading: Boolean(load) });
|
|
37
|
+
const [tick, setTick] = React.useState(0);
|
|
38
|
+
// The consumer's `api` is usually an object literal, so its methods are new
|
|
39
|
+
// on every parent render. Reading the latest through a ref keeps a render
|
|
40
|
+
// from cancelling and refiring the request it just made.
|
|
41
|
+
const loadRef = React.useRef(load);
|
|
42
|
+
React.useEffect(() => {
|
|
43
|
+
loadRef.current = load;
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
React.useEffect(() => {
|
|
47
|
+
const run = loadRef.current;
|
|
48
|
+
if (!run) {
|
|
49
|
+
setState({ data: null, error: null, loading: false });
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
const controller = new AbortController();
|
|
53
|
+
setState((s) => ({ ...s, loading: true }));
|
|
54
|
+
run(controller.signal).then(
|
|
55
|
+
(data) => {
|
|
56
|
+
if (!controller.signal.aborted) setState({ data, error: null, loading: false });
|
|
57
|
+
},
|
|
58
|
+
(err: unknown) => {
|
|
59
|
+
if (controller.signal.aborted) return;
|
|
60
|
+
// `adminErrorMessage` unconditionally, not only for a non-Error: the
|
|
61
|
+
// broker's envelope hangs off an `Error("HTTP 403")`, and taking the
|
|
62
|
+
// Error's own message showed the status where §15.6 asks for the
|
|
63
|
+
// server's sentence.
|
|
64
|
+
setState({ data: null, error: new Error(adminErrorMessage(err)), loading: false });
|
|
65
|
+
},
|
|
66
|
+
);
|
|
67
|
+
return () => controller.abort();
|
|
68
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
69
|
+
}, [...deps, tick]);
|
|
70
|
+
|
|
71
|
+
return { ...state, reload: React.useCallback(() => setTick((t) => t + 1), []) };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** `user:<id>` / `group:<id>` — the key an optimistic write is filed under. */
|
|
75
|
+
export function principalKey(principal: AdminPrincipal): string {
|
|
76
|
+
return `${principal.kind}:${principal.id}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface AdminPermissionsContextValue {
|
|
80
|
+
api: AdminPermissionsApi;
|
|
81
|
+
/** The declared vocabulary and matrix. `null` until it loads, or when it failed. */
|
|
82
|
+
matrix: AdminPermissionMatrix | null;
|
|
83
|
+
matrixState: Resource<AdminPermissionMatrix>;
|
|
84
|
+
/** Write the matrix. Optimistic: the grid shows the new grants before the PUT lands. */
|
|
85
|
+
saveMatrix: (next: AdminPermissionMatrix) => Promise<void>;
|
|
86
|
+
/**
|
|
87
|
+
* True when the API offers groups *and* the org actually has some. Every
|
|
88
|
+
* group affordance is hidden otherwise.
|
|
89
|
+
*/
|
|
90
|
+
hasGroups: boolean;
|
|
91
|
+
/**
|
|
92
|
+
* True while the answer to `hasGroups` is not known yet — the list is in
|
|
93
|
+
* flight, or it failed.
|
|
94
|
+
*
|
|
95
|
+
* `hasGroups === false` used to mean three different things at once: this
|
|
96
|
+
* API has no groups, this org has none, and `groups.list()` blew up. The
|
|
97
|
+
* third renders identically to the second, so a member who inherits
|
|
98
|
+
* "reviewer" from Legal is shown with no roles at all and an administrator
|
|
99
|
+
* reads that as a fact about the member. A failure has to look like a
|
|
100
|
+
* failure.
|
|
101
|
+
*/
|
|
102
|
+
groupsUnknown: boolean;
|
|
103
|
+
groups: AdminGroupRow[];
|
|
104
|
+
groupsState: Resource<{ rows: AdminGroupRow[]; roles: Record<string, string[]> }>;
|
|
105
|
+
/** group id → the roles it carries, including any not yet confirmed by the server. */
|
|
106
|
+
groupRoles: Readonly<Record<string, string[]>>;
|
|
107
|
+
/** Bumped after a successful assignment, so the members source refetches. */
|
|
108
|
+
revision: number;
|
|
109
|
+
/** Direct member roles written since the last refetch, by user id. */
|
|
110
|
+
memberRoles: ReadonlyMap<string, string[]>;
|
|
111
|
+
/**
|
|
112
|
+
* Assign a principal's direct roles. Optimistic: the screen shows the new
|
|
113
|
+
* roles immediately and puts the old ones back if the call rejects, then
|
|
114
|
+
* rethrows so the caller can show the envelope's message.
|
|
115
|
+
*/
|
|
116
|
+
assignRoles: (principal: AdminPrincipal, roles: string[]) => Promise<void>;
|
|
117
|
+
/**
|
|
118
|
+
* Hand back rows the server has just answered with, so any optimistic entry
|
|
119
|
+
* the server now agrees with can be dropped. See `memberRoles`.
|
|
120
|
+
*/
|
|
121
|
+
reconcileMembers: (rows: readonly AdminMemberRow[]) => void;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Same roles, order-insensitively — which is all an assignment is. */
|
|
125
|
+
function sameRoles(a: readonly string[], b: readonly string[]): boolean {
|
|
126
|
+
if (a.length !== b.length) return false;
|
|
127
|
+
const left = [...a].sort();
|
|
128
|
+
const right = [...b].sort();
|
|
129
|
+
return left.every((role, i) => role === right[i]);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const AdminPermissionsContext =
|
|
133
|
+
/* @__PURE__ */ React.createContext<AdminPermissionsContextValue | null>(null);
|
|
134
|
+
|
|
135
|
+
export function useAdminPermissions(): AdminPermissionsContextValue {
|
|
136
|
+
const value = React.useContext(AdminPermissionsContext);
|
|
137
|
+
if (!value) {
|
|
138
|
+
throw new Error("AdminPermissions parts must be rendered inside <AdminPermissions>");
|
|
139
|
+
}
|
|
140
|
+
return value;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const NO_GROUP_ROLES: Readonly<Record<string, string[]>> = /* @__PURE__ */ Object.freeze({});
|
|
144
|
+
|
|
145
|
+
export function AdminPermissionsProvider({
|
|
146
|
+
api,
|
|
147
|
+
children,
|
|
148
|
+
}: {
|
|
149
|
+
api: AdminPermissionsApi;
|
|
150
|
+
children: React.ReactNode;
|
|
151
|
+
}) {
|
|
152
|
+
const matrixState = useResource<AdminPermissionMatrix>(
|
|
153
|
+
React.useCallback((signal: AbortSignal) => api.permissions.get(signal), [api]),
|
|
154
|
+
[api],
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
const groupsApi = api.groups;
|
|
158
|
+
const loadGroups = React.useCallback(
|
|
159
|
+
async (signal: AbortSignal) => {
|
|
160
|
+
const [rows, roles] = await Promise.all([groupsApi!.list(signal), groupsApi!.roles(signal)]);
|
|
161
|
+
return { rows, roles };
|
|
162
|
+
},
|
|
163
|
+
[groupsApi],
|
|
164
|
+
);
|
|
165
|
+
const groupsState = useResource(groupsApi ? loadGroups : undefined, [loadGroups, Boolean(groupsApi)]);
|
|
166
|
+
|
|
167
|
+
const [revision, setRevision] = React.useState(0);
|
|
168
|
+
/** Optimistic layers. Both are keyed by principal id and win over the server's copy. */
|
|
169
|
+
const [memberRoles, setMemberRoles] = React.useState<ReadonlyMap<string, string[]>>(
|
|
170
|
+
() => new Map(),
|
|
171
|
+
);
|
|
172
|
+
const [groupOverrides, setGroupOverrides] = React.useState<ReadonlyMap<string, string[]>>(
|
|
173
|
+
() => new Map(),
|
|
174
|
+
);
|
|
175
|
+
const [matrixOverride, setMatrixOverride] = React.useState<AdminPermissionMatrix | null>(null);
|
|
176
|
+
/**
|
|
177
|
+
* Principals whose write the server has ACCEPTED.
|
|
178
|
+
*
|
|
179
|
+
* The optimistic entry has two jobs and they end at different moments: it
|
|
180
|
+
* covers the round trip (until the call resolves), and it covers the gap
|
|
181
|
+
* before the list refetches (until a response mentions the principal). Once
|
|
182
|
+
* both are done the entry is not optimism, it is a permanent local truth —
|
|
183
|
+
* and a second administrator's change to the same principal would never
|
|
184
|
+
* appear, however many times the page refetched.
|
|
185
|
+
*/
|
|
186
|
+
const settled = React.useRef({ members: new Set<string>(), groups: new Set<string>() });
|
|
187
|
+
|
|
188
|
+
const serverGroupRoles = groupsState.data?.roles;
|
|
189
|
+
const groupRoles = React.useMemo(() => {
|
|
190
|
+
if (!serverGroupRoles && groupOverrides.size === 0) return NO_GROUP_ROLES;
|
|
191
|
+
const merged: Record<string, string[]> = { ...(serverGroupRoles ?? {}) };
|
|
192
|
+
for (const [id, roles] of groupOverrides) merged[id] = roles;
|
|
193
|
+
return merged;
|
|
194
|
+
}, [serverGroupRoles, groupOverrides]);
|
|
195
|
+
|
|
196
|
+
const assignRoles = React.useCallback(
|
|
197
|
+
async (principal: AdminPrincipal, roles: string[]) => {
|
|
198
|
+
const next = [...roles];
|
|
199
|
+
if (principal.kind === "user") {
|
|
200
|
+
// Optimistic, then rolled back to exactly what was there before —
|
|
201
|
+
// absent, if this member had never been written from this screen.
|
|
202
|
+
const previous = memberRoles.get(principal.id);
|
|
203
|
+
setMemberRoles((prev) => new Map(prev).set(principal.id, next));
|
|
204
|
+
try {
|
|
205
|
+
await api.members.setRoles(principal.id, next);
|
|
206
|
+
settled.current.members.add(principal.id);
|
|
207
|
+
} catch (error) {
|
|
208
|
+
setMemberRoles((prev) => {
|
|
209
|
+
const back = new Map(prev);
|
|
210
|
+
if (previous === undefined) back.delete(principal.id);
|
|
211
|
+
else back.set(principal.id, previous);
|
|
212
|
+
return back;
|
|
213
|
+
});
|
|
214
|
+
throw error;
|
|
215
|
+
}
|
|
216
|
+
} else {
|
|
217
|
+
if (!api.groups) throw new Error("This API has no groups");
|
|
218
|
+
const previous = groupOverrides.get(principal.id);
|
|
219
|
+
setGroupOverrides((prev) => new Map(prev).set(principal.id, next));
|
|
220
|
+
try {
|
|
221
|
+
await api.groups.setRoles(principal.id, next);
|
|
222
|
+
settled.current.groups.add(principal.id);
|
|
223
|
+
} catch (error) {
|
|
224
|
+
setGroupOverrides((prev) => {
|
|
225
|
+
const back = new Map(prev);
|
|
226
|
+
if (previous === undefined) back.delete(principal.id);
|
|
227
|
+
else back.set(principal.id, previous);
|
|
228
|
+
return back;
|
|
229
|
+
});
|
|
230
|
+
throw error;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
// A group's roles change what every one of its members inherits, so the
|
|
234
|
+
// members page is refetched rather than left showing a stale resolution.
|
|
235
|
+
setRevision((r) => r + 1);
|
|
236
|
+
if (principal.kind === "group") {
|
|
237
|
+
// And the groups document is re-read, which is what retires the
|
|
238
|
+
// optimistic layer for this group: an override that outlives the
|
|
239
|
+
// server's agreement is a row that can no longer be corrected by
|
|
240
|
+
// anyone else.
|
|
241
|
+
groupsState.reload();
|
|
242
|
+
}
|
|
243
|
+
},
|
|
244
|
+
[api, memberRoles, groupOverrides, groupsState],
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Drop a member override the server has caught up with.
|
|
249
|
+
*
|
|
250
|
+
* The optimistic entry exists to cover the gap between a PATCH and the next
|
|
251
|
+
* list; once the list agrees, keeping it is not optimism but a permanent
|
|
252
|
+
* local truth. A second administrator's change to the same member would
|
|
253
|
+
* then never appear, however many times the page refetched — the row would
|
|
254
|
+
* go on showing what THIS browser last wrote, for the life of the tab.
|
|
255
|
+
*/
|
|
256
|
+
const reconcileMembers = React.useCallback((rows: readonly AdminMemberRow[]) => {
|
|
257
|
+
setMemberRoles((prev) => {
|
|
258
|
+
if (prev.size === 0) return prev;
|
|
259
|
+
let next: Map<string, string[]> | null = null;
|
|
260
|
+
for (const row of rows) {
|
|
261
|
+
const pending = prev.get(row.userId);
|
|
262
|
+
if (!pending) continue;
|
|
263
|
+
// Either the server already agrees, or the write it acknowledged has
|
|
264
|
+
// now been through a list — in which case this row IS the truth, even
|
|
265
|
+
// when it says something else, because somebody else changed it.
|
|
266
|
+
if (sameRoles(pending, row.extensionRoles ?? []) || settled.current.members.has(row.userId)) {
|
|
267
|
+
settled.current.members.delete(row.userId);
|
|
268
|
+
next ??= new Map(prev);
|
|
269
|
+
next.delete(row.userId);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return next ?? prev;
|
|
273
|
+
});
|
|
274
|
+
}, []);
|
|
275
|
+
|
|
276
|
+
// The same rule for groups: an override the server now echoes is retired.
|
|
277
|
+
React.useEffect(() => {
|
|
278
|
+
if (!serverGroupRoles) return;
|
|
279
|
+
setGroupOverrides((prev) => {
|
|
280
|
+
if (prev.size === 0) return prev;
|
|
281
|
+
let next: Map<string, string[]> | null = null;
|
|
282
|
+
for (const [id, roles] of prev) {
|
|
283
|
+
if (sameRoles(roles, serverGroupRoles[id] ?? []) || settled.current.groups.has(id)) {
|
|
284
|
+
settled.current.groups.delete(id);
|
|
285
|
+
next ??= new Map(prev);
|
|
286
|
+
next.delete(id);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return next ?? prev;
|
|
290
|
+
});
|
|
291
|
+
}, [serverGroupRoles]);
|
|
292
|
+
|
|
293
|
+
const saveMatrix = React.useCallback(
|
|
294
|
+
async (next: AdminPermissionMatrix) => {
|
|
295
|
+
const previous = matrixOverride;
|
|
296
|
+
setMatrixOverride(next);
|
|
297
|
+
try {
|
|
298
|
+
const saved = await api.permissions.put(next);
|
|
299
|
+
if (saved) setMatrixOverride(saved);
|
|
300
|
+
} catch (error) {
|
|
301
|
+
setMatrixOverride(previous);
|
|
302
|
+
throw error;
|
|
303
|
+
}
|
|
304
|
+
setRevision((r) => r + 1);
|
|
305
|
+
},
|
|
306
|
+
[api, matrixOverride],
|
|
307
|
+
);
|
|
308
|
+
|
|
309
|
+
const matrix = matrixOverride ?? matrixState.data;
|
|
310
|
+
const groups = groupsState.data?.rows ?? [];
|
|
311
|
+
|
|
312
|
+
const value = React.useMemo<AdminPermissionsContextValue>(
|
|
313
|
+
() => ({
|
|
314
|
+
api,
|
|
315
|
+
matrix,
|
|
316
|
+
matrixState,
|
|
317
|
+
saveMatrix,
|
|
318
|
+
hasGroups: Boolean(api.groups) && groups.length > 0,
|
|
319
|
+
groupsUnknown: Boolean(api.groups) && (groupsState.loading || groupsState.error !== null),
|
|
320
|
+
groups,
|
|
321
|
+
groupsState,
|
|
322
|
+
groupRoles,
|
|
323
|
+
revision,
|
|
324
|
+
memberRoles,
|
|
325
|
+
assignRoles,
|
|
326
|
+
reconcileMembers,
|
|
327
|
+
}),
|
|
328
|
+
// `groups` is derived from `groupsState`, which is in the list.
|
|
329
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
330
|
+
[
|
|
331
|
+
api,
|
|
332
|
+
matrix,
|
|
333
|
+
matrixState,
|
|
334
|
+
saveMatrix,
|
|
335
|
+
groupsState,
|
|
336
|
+
groupRoles,
|
|
337
|
+
revision,
|
|
338
|
+
memberRoles,
|
|
339
|
+
assignRoles,
|
|
340
|
+
reconcileMembers,
|
|
341
|
+
],
|
|
342
|
+
);
|
|
343
|
+
|
|
344
|
+
return <AdminPermissionsContext value={value}>{children}</AdminPermissionsContext>;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* The direct roles to show for a member: what the server sent, unless this
|
|
349
|
+
* screen has since written something else. A PATCH is not visible in the list
|
|
350
|
+
* until the source refetches, and on the local (no `QueryClientProvider`)
|
|
351
|
+
* strategy that may be a page change away — so the write is remembered rather
|
|
352
|
+
* than the row lying about it.
|
|
353
|
+
*/
|
|
354
|
+
export function directRolesOf(
|
|
355
|
+
row: AdminMemberRow,
|
|
356
|
+
memberRoles: ReadonlyMap<string, string[]>,
|
|
357
|
+
): string[] {
|
|
358
|
+
return memberRoles.get(row.userId) ?? row.extensionRoles ?? [];
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* The effective roles to show for a member after a local write. Inherited
|
|
363
|
+
* entries stay the server's — this screen does not know who belongs to which
|
|
364
|
+
* group — and the direct ones are replaced by whatever was just assigned.
|
|
365
|
+
*/
|
|
366
|
+
export function effectiveRolesWithOverride(
|
|
367
|
+
row: AdminMemberRow,
|
|
368
|
+
memberRoles: ReadonlyMap<string, string[]>,
|
|
369
|
+
): AdminEffectiveRole[] {
|
|
370
|
+
const override = memberRoles.get(row.userId);
|
|
371
|
+
const server = effectiveRolesOf(row);
|
|
372
|
+
if (!override) return server;
|
|
373
|
+
const inherited = server.filter((entry) => entry.source !== "direct");
|
|
374
|
+
const direct = override.map((role) => ({ role, source: "direct" as const }));
|
|
375
|
+
return [...direct, ...inherited];
|
|
376
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
export { AdminPermissions } from "./admin-permissions";
|
|
4
|
+
export type { AdminPermissionsProps } from "./admin-permissions";
|
|
5
|
+
export { AdminMembers } from "./members";
|
|
6
|
+
export { AdminRoles } from "./roles";
|
|
7
|
+
export { AdminLicenceScreen } from "./licence";
|
|
8
|
+
export { AdminMatrix } from "./matrix";
|
|
9
|
+
export { DirectRoles, EffectiveRoles, RoleAssignmentDialog } from "./role-assignment";
|
|
10
|
+
export type { RoleAssignmentDialogProps } from "./role-assignment";
|
|
11
|
+
export { useAdminPermissions, AdminPermissionsProvider } from "./context";
|
|
12
|
+
export type { AdminPermissionsContextValue } from "./context";
|
|
13
|
+
export {
|
|
14
|
+
ADMIN_ROUTES,
|
|
15
|
+
ADMIN_ROUTE_LABELS,
|
|
16
|
+
adminErrorMessage,
|
|
17
|
+
effectiveRolesOf,
|
|
18
|
+
sourceGroupOf,
|
|
19
|
+
} from "./types";
|
|
20
|
+
export type {
|
|
21
|
+
AdminEffectiveRole,
|
|
22
|
+
AdminErrorEnvelope,
|
|
23
|
+
AdminGroupRef,
|
|
24
|
+
AdminGroupRow,
|
|
25
|
+
AdminLicence,
|
|
26
|
+
AdminMemberRow,
|
|
27
|
+
AdminPermissionMatrix,
|
|
28
|
+
AdminPermissionsApi,
|
|
29
|
+
AdminPrincipal,
|
|
30
|
+
AdminRoleSource,
|
|
31
|
+
AdminRoute,
|
|
32
|
+
} from "./types";
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { Badge } from "@/components/badge";
|
|
5
|
+
import { Chip } from "@/components/chip";
|
|
6
|
+
import { ErrorBanner } from "@/components/error-banner";
|
|
7
|
+
import { LoadingSkeleton } from "@/components/loading-skeleton";
|
|
8
|
+
import { Progress, ProgressLabel, ProgressValue } from "@/components/progress";
|
|
9
|
+
import { SectionCard } from "@/components/section-card";
|
|
10
|
+
import { useAdminPermissions, useResource } from "./context";
|
|
11
|
+
import type { AdminLicence } from "./types";
|
|
12
|
+
|
|
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
|
+
export function AdminLicenceScreen() {
|
|
23
|
+
const { api } = useAdminPermissions();
|
|
24
|
+
const load = React.useCallback((signal: AbortSignal) => api.licence.get(signal), [api]);
|
|
25
|
+
const { data, error, loading, reload } = useResource<AdminLicence>(load, [load]);
|
|
26
|
+
|
|
27
|
+
if (error) {
|
|
28
|
+
return <ErrorBanner message={error.message} onRetry={reload} />;
|
|
29
|
+
}
|
|
30
|
+
if (loading && !data) {
|
|
31
|
+
return <LoadingSkeleton preset="card" data-slot="admin-licence-loading" />;
|
|
32
|
+
}
|
|
33
|
+
if (!data) return null;
|
|
34
|
+
|
|
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
|
+
|
|
39
|
+
return (
|
|
40
|
+
<div data-slot="admin-licence" className="flex flex-col gap-6">
|
|
41
|
+
<SectionCard
|
|
42
|
+
title="Licence"
|
|
43
|
+
description="Held by cortena-auth for this organisation. It is shown here and changed there."
|
|
44
|
+
actions={
|
|
45
|
+
<Badge variant="info" data-slot="admin-licence-plan">
|
|
46
|
+
{data.plan}
|
|
47
|
+
</Badge>
|
|
48
|
+
}
|
|
49
|
+
>
|
|
50
|
+
{seats === null ? (
|
|
51
|
+
<p className="text-[length:var(--ds-text-body-sm)] text-[color:var(--ds-muted-foreground)]">
|
|
52
|
+
This plan does not meter seats.
|
|
53
|
+
</p>
|
|
54
|
+
) : (
|
|
55
|
+
<Progress value={used ?? 0} max={seats} data-slot="admin-licence-seats">
|
|
56
|
+
<ProgressLabel>Seats used</ProgressLabel>
|
|
57
|
+
{/* Base UI hands the formatted value to a render function; the raw
|
|
58
|
+
count against the cap is what an administrator is looking for. */}
|
|
59
|
+
<ProgressValue>{() => `${used ?? 0} of ${seats}`}</ProgressValue>
|
|
60
|
+
</Progress>
|
|
61
|
+
)}
|
|
62
|
+
</SectionCard>
|
|
63
|
+
|
|
64
|
+
<SectionCard
|
|
65
|
+
title="Features"
|
|
66
|
+
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."
|
|
67
|
+
>
|
|
68
|
+
{features.length === 0 ? (
|
|
69
|
+
<p className="text-[length:var(--ds-text-body-sm)] text-[color:var(--ds-muted-foreground)]">
|
|
70
|
+
No features listed on this licence.
|
|
71
|
+
</p>
|
|
72
|
+
) : (
|
|
73
|
+
<div className="flex flex-wrap items-center gap-1.5" data-slot="admin-licence-features">
|
|
74
|
+
{features.map((feature) => (
|
|
75
|
+
<Chip key={feature} data-feature={feature}>
|
|
76
|
+
{feature}
|
|
77
|
+
</Chip>
|
|
78
|
+
))}
|
|
79
|
+
</div>
|
|
80
|
+
)}
|
|
81
|
+
</SectionCard>
|
|
82
|
+
</div>
|
|
83
|
+
);
|
|
84
|
+
}
|