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.
Files changed (138) hide show
  1. package/CHANGELOG.md +107 -0
  2. package/LICENSE +7 -0
  3. package/README.md +235 -3
  4. package/dist/a2ui/views.js +2 -2
  5. package/dist/agent-chat/a2ui-block.d.ts +60 -0
  6. package/dist/agent-chat/a2ui-block.js +69 -0
  7. package/dist/agent-chat/a2ui-block.js.map +1 -0
  8. package/dist/agent-chat/agui-client.d.ts +40 -0
  9. package/dist/agent-chat/agui-client.js +251 -0
  10. package/dist/agent-chat/agui-client.js.map +1 -0
  11. package/dist/agent-chat/bridge.d.ts +109 -0
  12. package/dist/agent-chat/bridge.js +353 -0
  13. package/dist/agent-chat/bridge.js.map +1 -0
  14. package/dist/agent-chat/session.d.ts +79 -0
  15. package/dist/agent-chat/session.js +391 -0
  16. package/dist/agent-chat/session.js.map +1 -0
  17. package/dist/agent-chat/step-label.d.ts +99 -0
  18. package/dist/agent-chat/step-label.js +116 -0
  19. package/dist/agent-chat/step-label.js.map +1 -0
  20. package/dist/agent-chat/store.d.ts +102 -0
  21. package/dist/agent-chat/store.js +876 -0
  22. package/dist/agent-chat/store.js.map +1 -0
  23. package/dist/agent-chat/types.d.ts +277 -0
  24. package/dist/agent-chat/types.js +17 -0
  25. package/dist/agent-chat/types.js.map +1 -0
  26. package/dist/agent-chat.d.ts +11 -0
  27. package/dist/agent-chat.js +11 -0
  28. package/dist/components/admin-permissions/admin-permissions.d.ts +66 -0
  29. package/dist/components/admin-permissions/admin-permissions.js +101 -0
  30. package/dist/components/admin-permissions/admin-permissions.js.map +1 -0
  31. package/dist/components/admin-permissions/context.d.ts +70 -0
  32. package/dist/components/admin-permissions/context.js +258 -0
  33. package/dist/components/admin-permissions/context.js.map +1 -0
  34. package/dist/components/admin-permissions/index.d.ts +10 -0
  35. package/dist/components/admin-permissions/licence.d.ts +15 -0
  36. package/dist/components/admin-permissions/licence.js +78 -0
  37. package/dist/components/admin-permissions/licence.js.map +1 -0
  38. package/dist/components/admin-permissions/matrix.d.ts +20 -0
  39. package/dist/components/admin-permissions/matrix.js +191 -0
  40. package/dist/components/admin-permissions/matrix.js.map +1 -0
  41. package/dist/components/admin-permissions/members.d.ts +18 -0
  42. package/dist/components/admin-permissions/members.js +185 -0
  43. package/dist/components/admin-permissions/members.js.map +1 -0
  44. package/dist/components/admin-permissions/role-assignment.d.ts +35 -0
  45. package/dist/components/admin-permissions/role-assignment.js +174 -0
  46. package/dist/components/admin-permissions/role-assignment.js.map +1 -0
  47. package/dist/components/admin-permissions/roles.d.ts +25 -0
  48. package/dist/components/admin-permissions/roles.js +168 -0
  49. package/dist/components/admin-permissions/roles.js.map +1 -0
  50. package/dist/components/admin-permissions/types.d.ts +152 -0
  51. package/dist/components/admin-permissions/types.js +63 -0
  52. package/dist/components/admin-permissions/types.js.map +1 -0
  53. package/dist/components/agent-chat-popup.d.ts +29 -0
  54. package/dist/components/agent-chat-popup.js +188 -0
  55. package/dist/components/agent-chat-popup.js.map +1 -0
  56. package/dist/components/agent-chat.d.ts +163 -0
  57. package/dist/components/agent-chat.js +673 -0
  58. package/dist/components/agent-chat.js.map +1 -0
  59. package/dist/components/app-shell.d.ts +126 -0
  60. package/dist/components/app-shell.js +297 -0
  61. package/dist/components/app-shell.js.map +1 -0
  62. package/dist/components/badge.d.ts +1 -1
  63. package/dist/components/button-link.js +1 -1
  64. package/dist/components/button.d.ts +1 -1
  65. package/dist/components/checkbox.d.ts +1 -1
  66. package/dist/components/combobox.d.ts +1 -1
  67. package/dist/components/combobox.js +1 -1
  68. package/dist/components/consent-screen.d.ts +65 -0
  69. package/dist/components/consent-screen.js +123 -0
  70. package/dist/components/consent-screen.js.map +1 -0
  71. package/dist/components/data-table/data-table.d.ts +15 -1
  72. package/dist/components/data-table/data-table.js +18 -4
  73. package/dist/components/data-table/data-table.js.map +1 -1
  74. package/dist/components/data-table/index.d.ts +4 -4
  75. package/dist/components/data-table/parts.d.ts +27 -3
  76. package/dist/components/data-table/parts.js +175 -55
  77. package/dist/components/data-table/parts.js.map +1 -1
  78. package/dist/components/data-table/types.d.ts +61 -0
  79. package/dist/components/data-table/use-data-table.js +91 -6
  80. package/dist/components/data-table/use-data-table.js.map +1 -1
  81. package/dist/components/data-table/use-server-source.js +119 -28
  82. package/dist/components/data-table/use-server-source.js.map +1 -1
  83. package/dist/components/help-panel.d.ts +131 -0
  84. package/dist/components/help-panel.js +545 -0
  85. package/dist/components/help-panel.js.map +1 -0
  86. package/dist/components/login-screen.d.ts +127 -0
  87. package/dist/components/login-screen.js +339 -0
  88. package/dist/components/login-screen.js.map +1 -0
  89. package/dist/components/session-guard.d.ts +268 -0
  90. package/dist/components/session-guard.js +632 -0
  91. package/dist/components/session-guard.js.map +1 -0
  92. package/dist/components/toast.d.ts +1 -1
  93. package/dist/core.d.ts +5 -1
  94. package/dist/core.js +11 -7
  95. package/dist/data-table.d.ts +13 -4
  96. package/dist/data-table.js +10 -2
  97. package/dist/hooks/use-cortena-theme.js +49 -3
  98. package/dist/hooks/use-cortena-theme.js.map +1 -1
  99. package/dist/index.d.ts +17 -4
  100. package/dist/index.js +21 -8
  101. package/dist/markdown.d.ts +2 -1
  102. package/dist/markdown.js +2 -1
  103. package/package.json +18 -5
  104. package/src/agent-chat/a2ui-block.ts +118 -0
  105. package/src/agent-chat/agui-client.ts +405 -0
  106. package/src/agent-chat/bridge.ts +445 -0
  107. package/src/agent-chat/session.ts +549 -0
  108. package/src/agent-chat/step-label.ts +177 -0
  109. package/src/agent-chat/store.ts +1234 -0
  110. package/src/agent-chat/types.ts +308 -0
  111. package/src/components/admin-permissions/admin-permissions.tsx +130 -0
  112. package/src/components/admin-permissions/context.tsx +376 -0
  113. package/src/components/admin-permissions/index.tsx +32 -0
  114. package/src/components/admin-permissions/licence.tsx +84 -0
  115. package/src/components/admin-permissions/matrix.tsx +257 -0
  116. package/src/components/admin-permissions/members.tsx +204 -0
  117. package/src/components/admin-permissions/role-assignment.tsx +239 -0
  118. package/src/components/admin-permissions/roles.tsx +169 -0
  119. package/src/components/admin-permissions/types.ts +231 -0
  120. package/src/components/agent-chat-popup.tsx +289 -0
  121. package/src/components/agent-chat.tsx +1006 -0
  122. package/src/components/app-shell.tsx +502 -0
  123. package/src/components/consent-screen.tsx +239 -0
  124. package/src/components/data-table/data-table.tsx +36 -0
  125. package/src/components/data-table/index.tsx +6 -1
  126. package/src/components/data-table/parts.tsx +223 -47
  127. package/src/components/data-table/types.ts +68 -0
  128. package/src/components/data-table/use-data-table.ts +152 -4
  129. package/src/components/data-table/use-server-source.ts +150 -12
  130. package/src/components/help-panel.tsx +765 -0
  131. package/src/components/login-screen.tsx +479 -0
  132. package/src/components/session-guard.tsx +1071 -0
  133. package/src/entries/agent-chat.ts +137 -0
  134. package/src/entries/core.ts +8 -0
  135. package/src/entries/data-table.ts +41 -0
  136. package/src/entries/markdown.ts +25 -0
  137. package/src/hooks/use-cortena-theme.ts +63 -4
  138. 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
+ }