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,308 @@
1
+ /**
2
+ * The transport contract `AgentChat` is written against.
3
+ *
4
+ * Everything the chat surface needs from a server is behind `AgentChatClient`,
5
+ * and nothing in the interface names AG-UI, HTTP or React. That is what makes
6
+ * the surface testable — `test/agent-chat.test.tsx` drives it from a fake that
7
+ * replays scripted event sequences — and it is what keeps `@ag-ui/client` out
8
+ * of every module except `./agui-client.ts`.
9
+ *
10
+ * The three sinks are subscriptions rather than constructor callbacks so one
11
+ * client can outlive several mounts of the component, which is what a pop-up
12
+ * that collapses and reopens mid-run does.
13
+ */
14
+
15
+ import type { A2UIChatBlock } from "./a2ui-block";
16
+
17
+ /* ── what a session is ───────────────────────────────────────────────────── */
18
+
19
+ /**
20
+ * A row of `sessions.list`.
21
+ *
22
+ * Titles fall back in a fixed order (§17.2 of the extension how-to):
23
+ * `label`, `autoTitle`, `derivedTitle`, `displayName`, `lastMessagePreview`,
24
+ * then "New conversation". `sessionTitle` below is that order, in one place.
25
+ */
26
+ export interface AgentSession {
27
+ /** The session key, verbatim — this is also the AG-UI `threadId`. */
28
+ key: string;
29
+ label?: string | null;
30
+ autoTitle?: string | null;
31
+ derivedTitle?: string | null;
32
+ displayName?: string | null;
33
+ lastMessagePreview?: string | null;
34
+ updatedAt?: number | null;
35
+ }
36
+
37
+ /** The title to show for a session row. */
38
+ export function sessionTitle(session: AgentSession): string {
39
+ for (const candidate of [
40
+ session.label,
41
+ session.autoTitle,
42
+ session.derivedTitle,
43
+ session.displayName,
44
+ session.lastMessagePreview,
45
+ ]) {
46
+ if (typeof candidate === "string" && candidate.trim()) {
47
+ return candidate.trim();
48
+ }
49
+ }
50
+ return "New conversation";
51
+ }
52
+
53
+ /* ── what a message is ───────────────────────────────────────────────────── */
54
+
55
+ export interface AgentChatMessage {
56
+ id: string;
57
+ role: "user" | "assistant";
58
+ text: string;
59
+ timestamp: number;
60
+ attachments?: string[];
61
+ /** The chain of thought behind this turn, collapsed by default. */
62
+ thinkingText?: string;
63
+ isError?: boolean;
64
+ /** True when the wire record was a tool call or a tool result; hidden. */
65
+ isToolMessage?: boolean;
66
+ /**
67
+ * The run that produced this message. Absent on anything loaded from
68
+ * history. A `final` folds into the previous bubble only when the two share
69
+ * this value, so one run's answer cannot be rewritten by the next one's.
70
+ */
71
+ runId?: string;
72
+ /** UI the agent drew, carried outside the prose. */
73
+ a2ui?: A2UIChatBlock[];
74
+ }
75
+
76
+ /* ── what a step is ──────────────────────────────────────────────────────── */
77
+
78
+ /**
79
+ * One tool call of the turn, in plain words (SKILLS-9).
80
+ *
81
+ * A step and an `AgentToolEntry` are the same call seen from two distances.
82
+ * The entry is the record — name, arguments, output, the `ui://` resource an
83
+ * MCP App is mounted from — and it is what `renderToolCall` is handed. The step
84
+ * is the sentence: what the agent is doing, now, for someone who is waiting and
85
+ * is not going to open a JSON blob to find out.
86
+ *
87
+ * `label` is stored rather than derived at render time because it is derived
88
+ * from the arguments, and the arguments arrive AFTER the start event. Keeping
89
+ * the computed sentence on the step means the strip is a pure function of state
90
+ * and the recompute happens once, where the arguments land.
91
+ */
92
+ export interface AgentChatStep {
93
+ /** The AG-UI `toolCallId`; the same id the tool strip's entry carries. */
94
+ id: string;
95
+ toolName: string;
96
+ /**
97
+ * The call's input, when it parsed as an object. Absent until it arrives.
98
+ *
99
+ * In memory only. It is NOT persisted — see `storedStep` in `session.ts` —
100
+ * because a tool call's arguments are the user's data: a search intent, a
101
+ * customer id, the body of a record being written.
102
+ */
103
+ args?: Record<string, unknown>;
104
+ /** The plain sentence. See `step-label.ts` for the map that produces it. */
105
+ label: string;
106
+ startedAt: number;
107
+ /**
108
+ * `stopped` is the user's Stop button, and is deliberately not `error`:
109
+ * nothing failed, the answer was simply no longer wanted, and a red cross
110
+ * against a call the user cancelled reads as a fault they caused.
111
+ *
112
+ * `paused` is the run ending on an interrupt with the call still OPEN — the
113
+ * producer is waiting for an approval or for a frontend tool to run — and it
114
+ * is deliberately not `done`: nothing has happened yet, and a tick against a
115
+ * write the user has not agreed to is a lie about their data. It stops the
116
+ * spinner and the elapsed counter, resolves to `done` or `error` when the
117
+ * run resumes and the result arrives, and becomes `stopped` on a deny.
118
+ */
119
+ status: "running" | "done" | "error" | "stopped" | "paused";
120
+ endedAt?: number;
121
+ /** The failure to show, on a step that failed. Bounded and sanitised. */
122
+ errorMessage?: string;
123
+ }
124
+
125
+ /** The result detail cortenacore puts on `TOOL_CALL_RESULT.metadata.meta`. */
126
+ export interface AgentToolResultMeta {
127
+ status?: number;
128
+ code?: string;
129
+ message?: string;
130
+ }
131
+
132
+ /**
133
+ * `TOOL_CALL_RESULT.metadata`, normalised.
134
+ *
135
+ * This is the ONLY thing that says whether a tool call failed. `content` is
136
+ * the result as the model sees it — for a Cortena tool, a JSON string of
137
+ * `{ content: [{ type: "text", text }], details }` — and reading an envelope
138
+ * out of it means guessing at a shape the producer never promised. The client
139
+ * did guess, for `ok: false` at the top level, and the guess was simply wrong:
140
+ * the top level is the tool-result wrapper, so every failed call was drawn as
141
+ * a success.
142
+ *
143
+ * `meta` is optional, and where it is absent only `isError` can be trusted.
144
+ */
145
+ export interface AgentToolResultMetadata {
146
+ toolName?: string;
147
+ isError: boolean;
148
+ meta?: AgentToolResultMeta;
149
+ }
150
+
151
+ /* ── the three sinks ─────────────────────────────────────────────────────── */
152
+
153
+ /**
154
+ * One chat event. The same `{ state: "delta" | "final" | "error" }` shape
155
+ * cortenacore broadcasts, so a client for a different transport has a
156
+ * documented target to hit.
157
+ *
158
+ * `delta` carries a SNAPSHOT, not an increment: every delta repeats all the
159
+ * text, all the thinking and every A2UI block seen so far. Two transports that
160
+ * disagree about this leave the store in different states for the same run.
161
+ */
162
+ export interface AgentChatEvent {
163
+ runId: string;
164
+ sessionKey: string;
165
+ seq: number;
166
+ state: "delta" | "final" | "error";
167
+ /**
168
+ * Set on a `final` that came from a `RUN_FINISHED` carrying
169
+ * `outcome.type === "interrupt"`.
170
+ *
171
+ * The run has stopped, but it has not FINISHED: cortenacore pauses it for an
172
+ * approval or for a frontend tool and leaves the tool call open, and the
173
+ * continuation is a second run that ends the same call. Without this the
174
+ * store read every interrupt as a success and ticked a write nobody had
175
+ * approved yet.
176
+ */
177
+ interrupted?: boolean;
178
+ message?: {
179
+ role?: string;
180
+ content?: Array<{ type: string; text?: string; thinking?: string; a2ui?: unknown }>;
181
+ timestamp?: number;
182
+ };
183
+ errorMessage?: string;
184
+ }
185
+
186
+ /** One step of the tool strip. */
187
+ export interface AgentToolEvent {
188
+ event: "tool.start" | "tool.args" | "tool.progress" | "tool.complete" | "tool.error";
189
+ data: {
190
+ id?: string;
191
+ name?: string;
192
+ /** `tool.args`: the call's input, parsed when it was valid JSON. */
193
+ input?: unknown;
194
+ output?: unknown;
195
+ /**
196
+ * `TOOL_CALL_RESULT.metadata`, forwarded verbatim on an end event.
197
+ *
198
+ * Untyped on purpose: it crosses a transport, so it is whatever arrived.
199
+ * `readResultMetadata` in `store.ts` is what turns it into an
200
+ * `AgentToolResultMetadata`, and it is the one place that decides whether
201
+ * a call failed.
202
+ */
203
+ metadata?: Record<string, unknown>;
204
+ startedAt?: number;
205
+ completedAt?: number;
206
+ };
207
+ }
208
+
209
+ /** Decisions cortenacore accepts on `POST /agui/approval`. */
210
+ export type AgentApprovalDecision = "allow-once" | "allow-always" | "deny";
211
+
212
+ export interface AgentApprovalRequest {
213
+ id: string;
214
+ /** The command, or the tool, the approval is about. */
215
+ toolName: string;
216
+ args: Record<string, unknown>;
217
+ timestamp: number;
218
+ /**
219
+ * The run and the session this request arrived on, captured when it arrived.
220
+ *
221
+ * A decision is bound to them rather than resolved against whatever run
222
+ * happens to be in flight when the user presses a button. The two come
223
+ * apart routinely: an approval pauses a run, the user opens another session
224
+ * in the drawer or starts a second turn, and the client's idea of "the
225
+ * current thread" has moved on. Answering "allow" against the wrong thread
226
+ * is a command approved in a conversation nobody was asked about.
227
+ */
228
+ runId: string;
229
+ sessionKey: string;
230
+ /** Set when a decision was made and the server would not take it. */
231
+ error?: string;
232
+ }
233
+
234
+ /** Where an approval came from, and therefore where the decision must go. */
235
+ export interface AgentApprovalContext {
236
+ runId: string;
237
+ sessionKey: string;
238
+ }
239
+
240
+ export type AgentApprovalEvent =
241
+ | { type: "request"; request: AgentApprovalRequest }
242
+ /** `decision: null` is an expiry. It is treated as a denial. */
243
+ | { type: "resolved"; id: string; decision: AgentApprovalDecision | null };
244
+
245
+ /* ── the client ──────────────────────────────────────────────────────────── */
246
+
247
+ export interface AgentChatSendInput {
248
+ /** The session key to run in. `threadId` is this value verbatim. */
249
+ sessionKey: string;
250
+ /** What the user typed. Empty for an action sent by rendered UI. */
251
+ message: string;
252
+ /** The run id; also the idempotency key. */
253
+ runId: string;
254
+ attachments?: unknown[];
255
+ thinking?: "low" | "medium" | "high";
256
+ model?: { provider: string; model: string };
257
+ /** A click inside an A2UI surface. Never a sentence the user appears to type. */
258
+ a2uiAction?: { surfaceId: string; componentId: string; name: string; payload: Record<string, unknown> };
259
+ /** The same, from inside a rendered MCP App. */
260
+ mcpAppAction?: Record<string, unknown>;
261
+ }
262
+
263
+ /**
264
+ * Everything `AgentChat` needs from a server.
265
+ *
266
+ * `request` is deliberately a method name plus params rather than a URL: the
267
+ * two calls the chat makes — `sessions.list` and `chat.history` — are control
268
+ * plane methods, and a host that proxies them through its own gateway chooses
269
+ * its own paths.
270
+ */
271
+ export interface AgentChatClient {
272
+ /** The agent this client talks to. Session keys are `agent:<agentId>:…`. */
273
+ readonly agentId: string;
274
+ /** Start a run. Resolves when the run has been accepted, not when it ends. */
275
+ send(input: AgentChatSendInput): Promise<void>;
276
+ /** Stop the run in flight. Best-effort; local state resets either way. */
277
+ abort(params?: { runId?: string; sessionKey?: string }): Promise<void>;
278
+ /**
279
+ * Answer an approval. `context` is the `{ runId, sessionKey }` the request
280
+ * carried, not the run in flight — see `AgentApprovalRequest`. Rejects when
281
+ * the server refuses the decision, so the caller can put the card back.
282
+ */
283
+ resolveApproval(
284
+ id: string,
285
+ decision: AgentApprovalDecision,
286
+ context: AgentApprovalContext,
287
+ ): Promise<void>;
288
+ /** `sessions.list` and `chat.history`. */
289
+ request<T = unknown>(method: string, params: Record<string, unknown>): Promise<T>;
290
+ /** Chat deltas, finals and errors. Returns an unsubscribe. */
291
+ onChat(sink: (event: AgentChatEvent) => void): () => void;
292
+ /** Tool starts, arguments, progress and results. Returns an unsubscribe. */
293
+ onTool(sink: (event: AgentToolEvent) => void): () => void;
294
+ /** Approval requests and resolutions. Returns an unsubscribe. */
295
+ onApproval(sink: (event: AgentApprovalEvent) => void): () => void;
296
+ }
297
+
298
+ /** What `sessions.list` answers. */
299
+ export interface AgentSessionsListResponse {
300
+ sessions?: AgentSession[];
301
+ }
302
+
303
+ /** What `chat.history` answers. `messages` are raw transcript records. */
304
+ export interface AgentChatHistoryResponse {
305
+ messages?: unknown[];
306
+ hasMore?: boolean;
307
+ total?: number;
308
+ }
@@ -0,0 +1,130 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { PageHeader } from "@/components/page-header";
5
+ import { Tabs, TabsList, TabsTrigger } from "@/components/tabs";
6
+ import { cn } from "@/lib/cn";
7
+ import { AdminPermissionsProvider } from "./context";
8
+ import { AdminLicenceScreen } from "./licence";
9
+ import { AdminMatrix } from "./matrix";
10
+ import { AdminMembers } from "./members";
11
+ import { AdminRoles } from "./roles";
12
+ import {
13
+ ADMIN_ROUTES,
14
+ ADMIN_ROUTE_LABELS,
15
+ type AdminPermissionsApi,
16
+ type AdminRoute,
17
+ } from "./types";
18
+
19
+ export interface AdminPermissionsProps
20
+ extends Omit<React.ComponentProps<"div">, "children" | "title"> {
21
+ /** Everything the screen reads and writes. The extension's, entirely. */
22
+ api: AdminPermissionsApi;
23
+ /**
24
+ * The screen to show. Pass it and the host's router owns the URL; omit it
25
+ * and the composition keeps the current screen in its own state.
26
+ */
27
+ route?: AdminRoute;
28
+ /** The screen to open with when `route` is not passed. @default "members" */
29
+ defaultRoute?: AdminRoute;
30
+ /**
31
+ * Fired when the administrator picks another screen. Always fired, whether
32
+ * or not `route` is controlled, so a host can push the URL without also
33
+ * having to hold the state.
34
+ */
35
+ onNavigate?: (route: AdminRoute) => void;
36
+ /** `false` hides the title block, for a host that renders its own. */
37
+ header?: boolean;
38
+ title?: React.ReactNode;
39
+ description?: React.ReactNode;
40
+ }
41
+
42
+ /**
43
+ * AdminPermissions — the consistent admin screen for every Cortena extension
44
+ * (§13, DESIGN-D11 as amended by DESIGN-D22; audit rules P-14, P-42, P-43).
45
+ *
46
+ * Four screens behind `admin/*`: `/admin/members`, `/admin/roles`,
47
+ * `/admin/licence`, `/admin/permissions`. It renders whatever roles and
48
+ * actions the extension declares and **defines none of its own** — there is
49
+ * not one role name, action name or grant in this folder. That is exactly what
50
+ * lets a verification workflow and a work tracker have completely different
51
+ * role models and still look like one product.
52
+ *
53
+ * ```tsx
54
+ * <Route path="admin/*" element={
55
+ * <AdminPermissions
56
+ * api={adminApi}
57
+ * route={screenFromPath(location.pathname)}
58
+ * onNavigate={(next) => navigate(`/admin/${next}`)}
59
+ * />
60
+ * } />
61
+ * ```
62
+ *
63
+ * **Router-agnostic, by a `route` prop plus `onNavigate`.** The component
64
+ * imports no router and never touches `history` or `location`. Those four
65
+ * paths are real URLs — deep-linkable, bookmarkable, and answerable to the
66
+ * back button — and only the host's router can own a URL; a component that
67
+ * pushed history itself would fight whichever router the extension chose.
68
+ * `onNavigate` reports the intent, the host turns it into a navigation, and
69
+ * `route` comes back as the answer.
70
+ *
71
+ * An internal fallback exists for the case with no router in it — the guide,
72
+ * a test, a small app: omit `route` and the composition keeps the screen in
73
+ * its own state. `onNavigate` still fires, so a host can start uncontrolled
74
+ * and add a router later without changing anything else.
75
+ *
76
+ * The navigation is a real tablist rather than a row of links, because the
77
+ * choice is between panels of one screen; the host is free to hide it with
78
+ * `header={false}` and drive `route` from its own chrome.
79
+ */
80
+ export function AdminPermissions({
81
+ api,
82
+ route,
83
+ defaultRoute = "members",
84
+ onNavigate,
85
+ header = true,
86
+ title = "Administration",
87
+ description = "Who is in this organisation, what your roles let them do, and the licence behind it.",
88
+ className,
89
+ ...props
90
+ }: AdminPermissionsProps) {
91
+ const [internal, setInternal] = React.useState<AdminRoute>(defaultRoute);
92
+ const active = route ?? internal;
93
+
94
+ const go = (next: AdminRoute) => {
95
+ if (route === undefined) setInternal(next);
96
+ onNavigate?.(next);
97
+ };
98
+
99
+ return (
100
+ <AdminPermissionsProvider api={api}>
101
+ <div
102
+ data-slot="admin-permissions"
103
+ data-route={active}
104
+ className={cn("flex flex-col gap-5", className)}
105
+ {...props}
106
+ >
107
+ {header ? <PageHeader title={title} description={description} /> : null}
108
+ <Tabs
109
+ value={active}
110
+ onValueChange={(value) => go(value as AdminRoute)}
111
+ data-slot="admin-permissions-nav"
112
+ >
113
+ <TabsList aria-label="Administration screens">
114
+ {ADMIN_ROUTES.map((item) => (
115
+ <TabsTrigger key={item} value={item} data-route={item}>
116
+ {ADMIN_ROUTE_LABELS[item]}
117
+ </TabsTrigger>
118
+ ))}
119
+ </TabsList>
120
+ </Tabs>
121
+ <div data-slot="admin-permissions-screen">
122
+ {active === "members" ? <AdminMembers /> : null}
123
+ {active === "roles" ? <AdminRoles /> : null}
124
+ {active === "licence" ? <AdminLicenceScreen /> : null}
125
+ {active === "permissions" ? <AdminMatrix /> : null}
126
+ </div>
127
+ </div>
128
+ </AdminPermissionsProvider>
129
+ );
130
+ }