cortena-ui 1.4.2 → 1.5.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/LICENSE +7 -0
- package/README.md +149 -1
- 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 +349 -0
- package/dist/agent-chat/bridge.js.map +1 -0
- package/dist/agent-chat/session.d.ts +58 -0
- package/dist/agent-chat/session.js +249 -0
- package/dist/agent-chat/session.js.map +1 -0
- package/dist/agent-chat/store.d.ts +67 -0
- package/dist/agent-chat/store.js +548 -0
- package/dist/agent-chat/store.js.map +1 -0
- package/dist/agent-chat/types.d.ts +187 -0
- package/dist/agent-chat/types.js +17 -0
- package/dist/agent-chat/types.js.map +1 -0
- package/dist/agent-chat.d.ts +10 -0
- package/dist/agent-chat.js +10 -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 +143 -0
- package/dist/components/agent-chat.js +578 -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 +2 -2
- 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 +16 -4
- package/src/agent-chat/a2ui-block.ts +118 -0
- package/src/agent-chat/agui-client.ts +405 -0
- package/src/agent-chat/bridge.ts +433 -0
- package/src/agent-chat/session.ts +392 -0
- package/src/agent-chat/store.ts +738 -0
- package/src/agent-chat/types.ts +213 -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 +843 -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 +113 -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,168 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
"use client";
|
|
3
|
+
import { Button } from "../button.js";
|
|
4
|
+
import { Chip } from "../chip.js";
|
|
5
|
+
import { EmptyState } from "../empty-state.js";
|
|
6
|
+
import { ErrorBanner } from "../error-banner.js";
|
|
7
|
+
import { SectionCard } from "../section-card.js";
|
|
8
|
+
import { createColumnHelper } from "../data-table/features.js";
|
|
9
|
+
import { DataTable } from "../data-table/data-table.js";
|
|
10
|
+
import { useAdminPermissions } from "./context.js";
|
|
11
|
+
import { DirectRoles, RoleAssignmentDialog } from "./role-assignment.js";
|
|
12
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
13
|
+
import * as React from "react";
|
|
14
|
+
//#region src/components/admin-permissions/roles.tsx
|
|
15
|
+
const helper = /* @__PURE__ */ createColumnHelper();
|
|
16
|
+
/**
|
|
17
|
+
* `/admin/roles` — the extension's own vocabulary, and the group principals
|
|
18
|
+
* that hold it.
|
|
19
|
+
*
|
|
20
|
+
* Two halves. The first is what each declared role grants, read straight off
|
|
21
|
+
* the matrix, so an administrator deciding whether to hand somebody "editor"
|
|
22
|
+
* can see what "editor" means without leaving the screen. The second is group
|
|
23
|
+
* assignment: the other kind of principal a role attaches to (DESIGN-D22,
|
|
24
|
+
* P-42), on the same screen as the users because it is the same decision.
|
|
25
|
+
*
|
|
26
|
+
* The groups list is a **client** source, and the members list is not, for one
|
|
27
|
+
* reason: an org's groups are a short, bounded list that arrives in a single
|
|
28
|
+
* `groups.list()`, so paging it server-side would be a round-trip per keypress
|
|
29
|
+
* for nothing. Where the groups come from — a directory sync, or somebody
|
|
30
|
+
* typing them into cortena-auth — is invisible here by design.
|
|
31
|
+
*
|
|
32
|
+
* The whole section disappears when the api has no `groups`, and equally when
|
|
33
|
+
* it has some and the org has none.
|
|
34
|
+
*/
|
|
35
|
+
function AdminRoles() {
|
|
36
|
+
const { matrix, hasGroups, groups, groupsState, groupRoles } = useAdminPermissions();
|
|
37
|
+
const [editing, setEditing] = React.useState(null);
|
|
38
|
+
const [editingRoles, setEditingRoles] = React.useState([]);
|
|
39
|
+
const columns = React.useMemo(() => helper.columns([
|
|
40
|
+
helper.accessor("name", {
|
|
41
|
+
header: "Group",
|
|
42
|
+
cell: ({ row }) => /* @__PURE__ */ jsx("span", {
|
|
43
|
+
className: "font-medium text-[color:var(--ds-foreground)]",
|
|
44
|
+
children: row.original.name
|
|
45
|
+
}),
|
|
46
|
+
meta: { label: "Group" }
|
|
47
|
+
}),
|
|
48
|
+
helper.accessor("memberCount", {
|
|
49
|
+
header: "Members",
|
|
50
|
+
cell: ({ row }) => row.original.memberCount ?? "—",
|
|
51
|
+
meta: {
|
|
52
|
+
align: "end",
|
|
53
|
+
label: "Members"
|
|
54
|
+
}
|
|
55
|
+
}),
|
|
56
|
+
helper.display({
|
|
57
|
+
id: "roles",
|
|
58
|
+
header: "Roles",
|
|
59
|
+
cell: ({ row }) => /* @__PURE__ */ jsx(DirectRoles, { roles: groupRoles[row.original.id] ?? [] }),
|
|
60
|
+
meta: {
|
|
61
|
+
label: "Roles",
|
|
62
|
+
exportValue: (row) => (groupRoles[row.id] ?? []).join(", ")
|
|
63
|
+
},
|
|
64
|
+
enableSorting: false
|
|
65
|
+
}),
|
|
66
|
+
helper.display({
|
|
67
|
+
id: "actions",
|
|
68
|
+
header: () => /* @__PURE__ */ jsx("span", {
|
|
69
|
+
className: "sr-only",
|
|
70
|
+
children: "Actions"
|
|
71
|
+
}),
|
|
72
|
+
cell: ({ row }) => /* @__PURE__ */ jsx("div", {
|
|
73
|
+
className: "flex justify-end",
|
|
74
|
+
children: /* @__PURE__ */ jsx(Button, {
|
|
75
|
+
variant: "outline",
|
|
76
|
+
size: "sm",
|
|
77
|
+
"aria-label": `Edit roles for ${row.original.name}`,
|
|
78
|
+
onClick: () => {
|
|
79
|
+
setEditingRoles(groupRoles[row.original.id] ?? []);
|
|
80
|
+
setEditing({
|
|
81
|
+
kind: "group",
|
|
82
|
+
id: row.original.id,
|
|
83
|
+
name: row.original.name
|
|
84
|
+
});
|
|
85
|
+
},
|
|
86
|
+
children: "Edit roles"
|
|
87
|
+
})
|
|
88
|
+
}),
|
|
89
|
+
meta: {
|
|
90
|
+
align: "end",
|
|
91
|
+
exportable: false
|
|
92
|
+
},
|
|
93
|
+
enableSorting: false,
|
|
94
|
+
enableHiding: false
|
|
95
|
+
})
|
|
96
|
+
]), [groupRoles]);
|
|
97
|
+
const roles = matrix?.roles ?? [];
|
|
98
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
99
|
+
"data-slot": "admin-roles",
|
|
100
|
+
className: "flex flex-col gap-6",
|
|
101
|
+
children: [
|
|
102
|
+
/* @__PURE__ */ jsx(SectionCard, {
|
|
103
|
+
title: "Roles",
|
|
104
|
+
description: "Declared by this extension. Nothing on the platform reads these names, so they are the extension's to choose.",
|
|
105
|
+
children: roles.length === 0 ? /* @__PURE__ */ jsx(EmptyState, {
|
|
106
|
+
size: "compact",
|
|
107
|
+
title: "No roles declared",
|
|
108
|
+
description: "Roles come from this extension's permissions document. Until it declares some, there is nothing to assign."
|
|
109
|
+
}) : /* @__PURE__ */ jsx("ul", {
|
|
110
|
+
"data-slot": "admin-role-list",
|
|
111
|
+
className: "flex flex-col gap-2",
|
|
112
|
+
children: roles.map((role) => {
|
|
113
|
+
const granted = matrix?.grants[role] ?? [];
|
|
114
|
+
return /* @__PURE__ */ jsxs("li", {
|
|
115
|
+
"data-slot": "admin-role-summary",
|
|
116
|
+
"data-role": role,
|
|
117
|
+
className: "flex flex-col gap-1.5 rounded-[var(--ds-radius-md)] border border-[var(--ds-border-subtle)] bg-[var(--ds-muted)] px-3 py-2.5",
|
|
118
|
+
children: [/* @__PURE__ */ jsx("span", {
|
|
119
|
+
className: "text-[length:var(--ds-text-body-sm)] font-semibold text-[color:var(--ds-foreground)]",
|
|
120
|
+
children: role
|
|
121
|
+
}), granted.length === 0 ? /* @__PURE__ */ jsx("span", {
|
|
122
|
+
className: "text-[length:var(--ds-text-caption)] text-[color:var(--ds-text-tertiary)]",
|
|
123
|
+
children: "Grants nothing yet"
|
|
124
|
+
}) : /* @__PURE__ */ jsx("div", {
|
|
125
|
+
className: "flex flex-wrap items-center gap-1",
|
|
126
|
+
children: granted.map((action) => /* @__PURE__ */ jsx(Chip, {
|
|
127
|
+
size: "sm",
|
|
128
|
+
variant: "outline",
|
|
129
|
+
"data-action": action,
|
|
130
|
+
children: action
|
|
131
|
+
}, action))
|
|
132
|
+
})]
|
|
133
|
+
}, role);
|
|
134
|
+
})
|
|
135
|
+
})
|
|
136
|
+
}),
|
|
137
|
+
groupsState.error ? /* @__PURE__ */ jsx(ErrorBanner, {
|
|
138
|
+
message: groupsState.error.message,
|
|
139
|
+
onRetry: groupsState.reload
|
|
140
|
+
}) : null,
|
|
141
|
+
hasGroups ? /* @__PURE__ */ jsx(SectionCard, {
|
|
142
|
+
title: "Groups",
|
|
143
|
+
description: "Groups are cortena-auth's and read-only here. Every member of a group inherits the roles assigned to it, with no assignment write of their own.",
|
|
144
|
+
flush: true,
|
|
145
|
+
children: /* @__PURE__ */ jsx(DataTable, {
|
|
146
|
+
columns,
|
|
147
|
+
dataSource: {
|
|
148
|
+
kind: "client",
|
|
149
|
+
rows: groups
|
|
150
|
+
},
|
|
151
|
+
getRowId: (row) => row.id,
|
|
152
|
+
searchPlaceholder: "Search groups…",
|
|
153
|
+
emptyMessage: "No groups",
|
|
154
|
+
exportFileName: "groups"
|
|
155
|
+
})
|
|
156
|
+
}) : null,
|
|
157
|
+
/* @__PURE__ */ jsx(RoleAssignmentDialog, {
|
|
158
|
+
principal: editing,
|
|
159
|
+
current: editingRoles,
|
|
160
|
+
onOpenChange: (open) => !open && setEditing(null)
|
|
161
|
+
})
|
|
162
|
+
]
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
//#endregion
|
|
166
|
+
export { AdminRoles };
|
|
167
|
+
|
|
168
|
+
//# sourceMappingURL=roles.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"roles.js","names":[],"sources":["../../../src/components/admin-permissions/roles.tsx"],"sourcesContent":["\"use client\";\n\nimport * as React from \"react\";\nimport { Button } from \"@/components/button\";\nimport { Chip } from \"@/components/chip\";\nimport { DataTable, createColumnHelper } from \"@/components/data-table\";\nimport { EmptyState } from \"@/components/empty-state\";\nimport { ErrorBanner } from \"@/components/error-banner\";\nimport { SectionCard } from \"@/components/section-card\";\nimport { useAdminPermissions } from \"./context\";\nimport { DirectRoles, RoleAssignmentDialog } from \"./role-assignment\";\nimport type { AdminGroupRow, AdminPrincipal } from \"./types\";\n\nconst helper = /* @__PURE__ */ createColumnHelper<AdminGroupRow>();\n\n/**\n * `/admin/roles` — the extension's own vocabulary, and the group principals\n * that hold it.\n *\n * Two halves. The first is what each declared role grants, read straight off\n * the matrix, so an administrator deciding whether to hand somebody \"editor\"\n * can see what \"editor\" means without leaving the screen. The second is group\n * assignment: the other kind of principal a role attaches to (DESIGN-D22,\n * P-42), on the same screen as the users because it is the same decision.\n *\n * The groups list is a **client** source, and the members list is not, for one\n * reason: an org's groups are a short, bounded list that arrives in a single\n * `groups.list()`, so paging it server-side would be a round-trip per keypress\n * for nothing. Where the groups come from — a directory sync, or somebody\n * typing them into cortena-auth — is invisible here by design.\n *\n * The whole section disappears when the api has no `groups`, and equally when\n * it has some and the org has none.\n */\nexport function AdminRoles() {\n const { matrix, hasGroups, groups, groupsState, groupRoles } = useAdminPermissions();\n const [editing, setEditing] = React.useState<AdminPrincipal | null>(null);\n const [editingRoles, setEditingRoles] = React.useState<readonly string[]>([]);\n\n const columns = React.useMemo(\n () =>\n helper.columns([\n helper.accessor(\"name\", {\n header: \"Group\",\n cell: ({ row }) => (\n <span className=\"font-medium text-[color:var(--ds-foreground)]\">\n {row.original.name}\n </span>\n ),\n meta: { label: \"Group\" },\n }),\n helper.accessor(\"memberCount\", {\n header: \"Members\",\n cell: ({ row }) => row.original.memberCount ?? \"—\",\n meta: { align: \"end\", label: \"Members\" },\n }),\n helper.display({\n id: \"roles\",\n header: \"Roles\",\n cell: ({ row }) => <DirectRoles roles={groupRoles[row.original.id] ?? []} />,\n meta: {\n label: \"Roles\",\n exportValue: (row: AdminGroupRow) => (groupRoles[row.id] ?? []).join(\", \"),\n },\n enableSorting: false,\n }),\n helper.display({\n id: \"actions\",\n header: () => <span className=\"sr-only\">Actions</span>,\n cell: ({ row }) => (\n <div className=\"flex justify-end\">\n <Button\n variant=\"outline\"\n size=\"sm\"\n aria-label={`Edit roles for ${row.original.name}`}\n onClick={() => {\n setEditingRoles(groupRoles[row.original.id] ?? []);\n setEditing({ kind: \"group\", id: row.original.id, name: row.original.name });\n }}\n >\n Edit roles\n </Button>\n </div>\n ),\n meta: { align: \"end\", exportable: false },\n enableSorting: false,\n enableHiding: false,\n }),\n ]),\n [groupRoles],\n );\n\n const roles = matrix?.roles ?? [];\n\n return (\n <div data-slot=\"admin-roles\" className=\"flex flex-col gap-6\">\n <SectionCard\n title=\"Roles\"\n description=\"Declared by this extension. Nothing on the platform reads these names, so they are the extension's to choose.\"\n >\n {roles.length === 0 ? (\n <EmptyState\n size=\"compact\"\n title=\"No roles declared\"\n description=\"Roles come from this extension's permissions document. Until it declares some, there is nothing to assign.\"\n />\n ) : (\n <ul data-slot=\"admin-role-list\" className=\"flex flex-col gap-2\">\n {roles.map((role) => {\n const granted = matrix?.grants[role] ?? [];\n return (\n <li\n key={role}\n data-slot=\"admin-role-summary\"\n data-role={role}\n className=\"flex flex-col gap-1.5 rounded-[var(--ds-radius-md)] border border-[var(--ds-border-subtle)] bg-[var(--ds-muted)] px-3 py-2.5\"\n >\n <span className=\"text-[length:var(--ds-text-body-sm)] font-semibold text-[color:var(--ds-foreground)]\">\n {role}\n </span>\n {granted.length === 0 ? (\n <span className=\"text-[length:var(--ds-text-caption)] text-[color:var(--ds-text-tertiary)]\">\n Grants nothing yet\n </span>\n ) : (\n <div className=\"flex flex-wrap items-center gap-1\">\n {granted.map((action) => (\n <Chip key={action} size=\"sm\" variant=\"outline\" data-action={action}>\n {action}\n </Chip>\n ))}\n </div>\n )}\n </li>\n );\n })}\n </ul>\n )}\n </SectionCard>\n\n {groupsState.error ? (\n <ErrorBanner message={groupsState.error.message} onRetry={groupsState.reload} />\n ) : null}\n\n {hasGroups ? (\n <SectionCard\n title=\"Groups\"\n description=\"Groups are cortena-auth's and read-only here. Every member of a group inherits the roles assigned to it, with no assignment write of their own.\"\n flush\n >\n <DataTable\n columns={columns}\n dataSource={{ kind: \"client\", rows: groups }}\n getRowId={(row) => row.id}\n searchPlaceholder=\"Search groups…\"\n emptyMessage=\"No groups\"\n exportFileName=\"groups\"\n />\n </SectionCard>\n ) : null}\n\n <RoleAssignmentDialog\n principal={editing}\n current={editingRoles}\n onOpenChange={(open) => !open && setEditing(null)}\n />\n </div>\n );\n}\n"],"mappings":";;;;;;;;;;;;;;AAaA,MAAM,SAAyB,mCAAkC;;;;;;;;;;;;;;;;;;;;AAqBjE,SAAgB,aAAa;CAC3B,MAAM,EAAE,QAAQ,WAAW,QAAQ,aAAa,eAAe,oBAAoB;CACnF,MAAM,CAAC,SAAS,cAAc,MAAM,SAAgC,IAAI;CACxE,MAAM,CAAC,cAAc,mBAAmB,MAAM,SAA4B,CAAC,CAAC;CAE5E,MAAM,UAAU,MAAM,cAElB,OAAO,QAAQ;EACb,OAAO,SAAS,QAAQ;GACtB,QAAQ;GACR,OAAO,EAAE,UACP,oBAAC,QAAD;IAAM,WAAU;IACb,UAAA,IAAI,SAAS;GACV,CAAA;GAER,MAAM,EAAE,OAAO,QAAQ;EACzB,CAAC;EACD,OAAO,SAAS,eAAe;GAC7B,QAAQ;GACR,OAAO,EAAE,UAAU,IAAI,SAAS,eAAe;GAC/C,MAAM;IAAE,OAAO;IAAO,OAAO;GAAU;EACzC,CAAC;EACD,OAAO,QAAQ;GACb,IAAI;GACJ,QAAQ;GACR,OAAO,EAAE,UAAU,oBAAC,aAAD,EAAa,OAAO,WAAW,IAAI,SAAS,OAAO,CAAC,EAAI,CAAA;GAC3E,MAAM;IACJ,OAAO;IACP,cAAc,SAAwB,WAAW,IAAI,OAAO,CAAC,EAAA,CAAG,KAAK,IAAI;GAC3E;GACA,eAAe;EACjB,CAAC;EACD,OAAO,QAAQ;GACb,IAAI;GACJ,cAAc,oBAAC,QAAD;IAAM,WAAU;IAAU,UAAA;GAAa,CAAA;GACrD,OAAO,EAAE,UACP,oBAAC,OAAD;IAAK,WAAU;IACb,UAAA,oBAAC,QAAD;KACE,SAAQ;KACR,MAAK;KACL,cAAY,kBAAkB,IAAI,SAAS;KAC3C,eAAe;MACb,gBAAgB,WAAW,IAAI,SAAS,OAAO,CAAC,CAAC;MACjD,WAAW;OAAE,MAAM;OAAS,IAAI,IAAI,SAAS;OAAI,MAAM,IAAI,SAAS;MAAK,CAAC;KAC5E;KACD,UAAA;IAEO,CAAA;GACL,CAAA;GAEP,MAAM;IAAE,OAAO;IAAO,YAAY;GAAM;GACxC,eAAe;GACf,cAAc;EAChB,CAAC;CACH,CAAC,GACH,CAAC,UAAU,CACb;CAEA,MAAM,QAAQ,QAAQ,SAAS,CAAC;CAEhC,OACE,qBAAC,OAAD;EAAK,aAAU;EAAc,WAAU;EAAvC,UAAA;GACE,oBAAC,aAAD;IACE,OAAM;IACN,aAAY;IAEX,UAAA,MAAM,WAAW,IAChB,oBAAC,YAAD;KACE,MAAK;KACL,OAAM;KACN,aAAY;IACb,CAAA,IAED,oBAAC,MAAD;KAAI,aAAU;KAAkB,WAAU;KACvC,UAAA,MAAM,KAAK,SAAS;MACnB,MAAM,UAAU,QAAQ,OAAO,SAAS,CAAC;MACzC,OACE,qBAAC,MAAD;OAEE,aAAU;OACV,aAAW;OACX,WAAU;OAJZ,UAAA,CAME,oBAAC,QAAD;QAAM,WAAU;QACb,UAAA;OACG,CAAA,GACL,QAAQ,WAAW,IAClB,oBAAC,QAAD;QAAM,WAAU;QAA4E,UAAA;OAEtF,CAAA,IAEN,oBAAC,OAAD;QAAK,WAAU;QACZ,UAAA,QAAQ,KAAK,WACZ,oBAAC,MAAD;SAAmB,MAAK;SAAK,SAAQ;SAAU,eAAa;SACzD,UAAA;QACG,GAFK,MAEL,CACP;OACE,CAAA,CAEL;MArBG,GAAA,IAqBH;KAER,CAAC;IACC,CAAA;GAEK,CAAA;GAEZ,YAAY,QACX,oBAAC,aAAD;IAAa,SAAS,YAAY,MAAM;IAAS,SAAS,YAAY;GAAS,CAAA,IAC7E;GAEH,YACC,oBAAC,aAAD;IACE,OAAM;IACN,aAAY;IACZ,OAAA;IAEA,UAAA,oBAAC,WAAD;KACW;KACT,YAAY;MAAE,MAAM;MAAU,MAAM;KAAO;KAC3C,WAAW,QAAQ,IAAI;KACvB,mBAAkB;KAClB,cAAa;KACb,gBAAe;IAChB,CAAA;GACU,CAAA,IACX;GAEJ,oBAAC,sBAAD;IACE,WAAW;IACX,SAAS;IACT,eAAe,SAAS,CAAC,QAAQ,WAAW,IAAI;GACjD,CAAA;EACE;;AAET"}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { TableQuery, TableResult } from "../data-table/types.js";
|
|
3
|
+
import "../data-table/index.js";
|
|
4
|
+
//#region src/components/admin-permissions/types.d.ts
|
|
5
|
+
/**
|
|
6
|
+
* A role is assigned to a principal, never to a user id (DESIGN-D22, §13.4).
|
|
7
|
+
* Both kinds are edited on the same screen and through the same dialog; only
|
|
8
|
+
* the method underneath differs.
|
|
9
|
+
*/
|
|
10
|
+
export interface AdminPrincipal {
|
|
11
|
+
kind: "user" | "group";
|
|
12
|
+
/** cortena-auth user id, or cortena-auth group id. */
|
|
13
|
+
id: string;
|
|
14
|
+
/** Display name, for the dialog title. */
|
|
15
|
+
name?: string;
|
|
16
|
+
}
|
|
17
|
+
/** A group, as a member's row refers to it. Defined and maintained in cortena-auth. */
|
|
18
|
+
export interface AdminGroupRef {
|
|
19
|
+
id: string;
|
|
20
|
+
name: string;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Why a member holds an effective role: assigned to them directly, or carried
|
|
24
|
+
* by a group they belong to. Never dropped from the rendering — an
|
|
25
|
+
* administrator who cannot see *why* somebody has a permission removes the
|
|
26
|
+
* wrong assignment and believes the job is done (P-43).
|
|
27
|
+
*/
|
|
28
|
+
export type AdminRoleSource = "direct" | {
|
|
29
|
+
group: AdminGroupRef;
|
|
30
|
+
};
|
|
31
|
+
/** One entry of `effectiveRoles`: the role, and the assignment that granted it. */
|
|
32
|
+
export interface AdminEffectiveRole {
|
|
33
|
+
role: string;
|
|
34
|
+
source: AdminRoleSource;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* A row of `members.list`. A join: the person and their groups are
|
|
38
|
+
* cortena-auth's and read-only; `extensionRoles` is the extension's own direct
|
|
39
|
+
* assignment; `effectiveRoles` is the per-request resolution of direct +
|
|
40
|
+
* inherited that `may()` performs, rendered — never a stored table (P-43).
|
|
41
|
+
*/
|
|
42
|
+
export interface AdminMemberRow {
|
|
43
|
+
userId: string;
|
|
44
|
+
name: string;
|
|
45
|
+
email: string;
|
|
46
|
+
status: string;
|
|
47
|
+
/** ISO timestamp, or null when the user has never signed in. */
|
|
48
|
+
lastActiveAt: string | null;
|
|
49
|
+
groups: AdminGroupRef[];
|
|
50
|
+
extensionRoles: string[];
|
|
51
|
+
effectiveRoles: AdminEffectiveRole[];
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* A row of `groups.list` — read-only as to the group itself. The roles a group
|
|
55
|
+
* carries are the extension's and come back separately from `groups.roles()`,
|
|
56
|
+
* because the group list is identity's and the assignment map is not.
|
|
57
|
+
*/
|
|
58
|
+
export interface AdminGroupRow {
|
|
59
|
+
id: string;
|
|
60
|
+
name: string;
|
|
61
|
+
memberCount: number;
|
|
62
|
+
}
|
|
63
|
+
/** `licence.get` — a read-through display of cortena-auth's licence. */
|
|
64
|
+
export interface AdminLicence {
|
|
65
|
+
plan: string;
|
|
66
|
+
seats: number | null;
|
|
67
|
+
seatsUsed: number | null;
|
|
68
|
+
features: string[];
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* `permissions.get` / `permissions.put` — the extension's own matrix, in the
|
|
72
|
+
* extension's own database. This is the source of truth that both the REST
|
|
73
|
+
* routes and the MCP tools enforce through one `may()` (§13.2, P-14).
|
|
74
|
+
*/
|
|
75
|
+
export interface AdminPermissionMatrix {
|
|
76
|
+
roles: string[];
|
|
77
|
+
actions: string[];
|
|
78
|
+
/** role → the actions it grants. A role absent from the map grants nothing. */
|
|
79
|
+
grants: Record<string, string[]>;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Everything the composition talks to, one namespace per resource in §13.3.
|
|
83
|
+
*
|
|
84
|
+
* `groups` is the only optional member, and it is optional for a reason rather
|
|
85
|
+
* than for convenience: an extension whose cortena-auth tenant has no groups
|
|
86
|
+
* omits it, and every group affordance disappears instead of rendering empty.
|
|
87
|
+
* It disappears equally when `groups.list()` comes back empty — an empty
|
|
88
|
+
* "Groups" section reads as a broken feature, and there is nothing an
|
|
89
|
+
* administrator can do about it from here.
|
|
90
|
+
*/
|
|
91
|
+
export interface AdminPermissionsApi {
|
|
92
|
+
members: {
|
|
93
|
+
/** `GET .../admin/members` — the DataTable server source. */
|
|
94
|
+
list: (query: TableQuery, signal?: AbortSignal) => Promise<TableResult<AdminMemberRow>>;
|
|
95
|
+
/** `PATCH .../admin/members/:userId { extensionRoles }` — direct assignment only. */
|
|
96
|
+
setRoles: (userId: string, roles: string[]) => Promise<void>;
|
|
97
|
+
};
|
|
98
|
+
groups?: {
|
|
99
|
+
/** `GET .../admin/groups` — read-only; the whole list, small enough to be a client source. */
|
|
100
|
+
list: (signal?: AbortSignal) => Promise<AdminGroupRow[]>;
|
|
101
|
+
/** The extension's assignment: group id → the roles that group carries. */
|
|
102
|
+
roles: (signal?: AbortSignal) => Promise<Record<string, string[]>>;
|
|
103
|
+
/** `PATCH .../admin/groups/:groupId { extensionRoles }` */
|
|
104
|
+
setRoles: (groupId: string, roles: string[]) => Promise<void>;
|
|
105
|
+
};
|
|
106
|
+
licence: {
|
|
107
|
+
/** `GET .../admin/licence` — read-only, always. */
|
|
108
|
+
get: (signal?: AbortSignal) => Promise<AdminLicence>;
|
|
109
|
+
};
|
|
110
|
+
permissions: {
|
|
111
|
+
/** `GET .../admin/permissions` */
|
|
112
|
+
get: (signal?: AbortSignal) => Promise<AdminPermissionMatrix>;
|
|
113
|
+
/** `PUT .../admin/permissions` */
|
|
114
|
+
put: (matrix: AdminPermissionMatrix, signal?: AbortSignal) => Promise<AdminPermissionMatrix | void>;
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
/** The four screens, named after their paths under `admin/`. */
|
|
118
|
+
export type AdminRoute = "members" | "roles" | "licence" | "permissions";
|
|
119
|
+
export declare const ADMIN_ROUTES: readonly AdminRoute[];
|
|
120
|
+
export declare const ADMIN_ROUTE_LABELS: Record<AdminRoute, string>;
|
|
121
|
+
/**
|
|
122
|
+
* The broker's error envelope (§15.6). A failed mutation must show what the
|
|
123
|
+
* server said — "assigneeId: Required" and "Invalid input" cost the same to
|
|
124
|
+
* produce and differ by a whole round-trip — so the rejection is unwrapped
|
|
125
|
+
* rather than replaced with a house message.
|
|
126
|
+
*/
|
|
127
|
+
export interface AdminErrorEnvelope {
|
|
128
|
+
ok: false;
|
|
129
|
+
status?: number;
|
|
130
|
+
code?: string;
|
|
131
|
+
message: string;
|
|
132
|
+
details?: unknown;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* The message to show for a rejection.
|
|
136
|
+
*
|
|
137
|
+
* A fetch wrapper usually rejects with an `Error` and hangs the parsed
|
|
138
|
+
* envelope off it, so the envelope is preferred over the generic
|
|
139
|
+
* `"HTTP 403"` the Error itself carries. A rejection that *is* the envelope,
|
|
140
|
+
* and a plain `Error`, both work as well.
|
|
141
|
+
*/
|
|
142
|
+
export declare function adminErrorMessage(error: unknown): string;
|
|
143
|
+
/**
|
|
144
|
+
* The effective roles to render. When the server sends none, the direct
|
|
145
|
+
* assignments are shown as direct — which is what they are — rather than the
|
|
146
|
+
* screen inventing an inheritance it cannot know about.
|
|
147
|
+
*/
|
|
148
|
+
export declare function effectiveRolesOf(row: AdminMemberRow): AdminEffectiveRole[];
|
|
149
|
+
/** The group behind an effective role, or `null` for a direct assignment. */
|
|
150
|
+
export declare function sourceGroupOf(source: AdminRoleSource): AdminGroupRef | null;
|
|
151
|
+
//#endregion
|
|
152
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
//#region src/components/admin-permissions/types.ts
|
|
3
|
+
const ADMIN_ROUTES = [
|
|
4
|
+
"members",
|
|
5
|
+
"roles",
|
|
6
|
+
"licence",
|
|
7
|
+
"permissions"
|
|
8
|
+
];
|
|
9
|
+
const ADMIN_ROUTE_LABELS = {
|
|
10
|
+
members: "Members",
|
|
11
|
+
roles: "Roles",
|
|
12
|
+
licence: "Licence",
|
|
13
|
+
permissions: "Permissions"
|
|
14
|
+
};
|
|
15
|
+
function envelopeMessage(value) {
|
|
16
|
+
if (!value || typeof value !== "object") return null;
|
|
17
|
+
const message = value.message;
|
|
18
|
+
return typeof message === "string" && message.length > 0 ? message : null;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* The message to show for a rejection.
|
|
22
|
+
*
|
|
23
|
+
* A fetch wrapper usually rejects with an `Error` and hangs the parsed
|
|
24
|
+
* envelope off it, so the envelope is preferred over the generic
|
|
25
|
+
* `"HTTP 403"` the Error itself carries. A rejection that *is* the envelope,
|
|
26
|
+
* and a plain `Error`, both work as well.
|
|
27
|
+
*/
|
|
28
|
+
function adminErrorMessage(error) {
|
|
29
|
+
if (error && typeof error === "object") {
|
|
30
|
+
const carrier = error;
|
|
31
|
+
const nested = envelopeMessage(carrier.envelope) ?? envelopeMessage(carrier.body) ?? envelopeMessage(carrier.error);
|
|
32
|
+
if (nested) return nested;
|
|
33
|
+
}
|
|
34
|
+
if (error instanceof Error) return error.message || "Something went wrong";
|
|
35
|
+
const direct = envelopeMessage(error);
|
|
36
|
+
if (direct) return direct;
|
|
37
|
+
return String(error);
|
|
38
|
+
}
|
|
39
|
+
/** Tolerates a server that omits an array the contract says is required. */
|
|
40
|
+
function groupsOf(row) {
|
|
41
|
+
return row.groups ?? [];
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* The effective roles to render. When the server sends none, the direct
|
|
45
|
+
* assignments are shown as direct — which is what they are — rather than the
|
|
46
|
+
* screen inventing an inheritance it cannot know about.
|
|
47
|
+
*/
|
|
48
|
+
function effectiveRolesOf(row) {
|
|
49
|
+
if (row.effectiveRoles?.length) return row.effectiveRoles;
|
|
50
|
+
return (row.extensionRoles ?? []).map((role) => ({
|
|
51
|
+
role,
|
|
52
|
+
source: "direct"
|
|
53
|
+
}));
|
|
54
|
+
}
|
|
55
|
+
/** The group behind an effective role, or `null` for a direct assignment. */
|
|
56
|
+
function sourceGroupOf(source) {
|
|
57
|
+
if (source === "direct" || !source) return null;
|
|
58
|
+
return source.group ?? null;
|
|
59
|
+
}
|
|
60
|
+
//#endregion
|
|
61
|
+
export { ADMIN_ROUTES, ADMIN_ROUTE_LABELS, adminErrorMessage, effectiveRolesOf, groupsOf, sourceGroupOf };
|
|
62
|
+
|
|
63
|
+
//# sourceMappingURL=types.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.js","names":[],"sources":["../../../src/components/admin-permissions/types.ts"],"sourcesContent":["/**\n * The contract AdminPermissions renders, from §13.3 of\n * \"How to create a Cortena extension\" (DESIGN-D11, amended by DESIGN-D22).\n *\n * The composition ships **no vocabulary of its own**. Every role, every action\n * and every grant on screen arrives through the `api` prop; every principal\n * arrives through it too. That is what lets two extensions with completely\n * different role models look like one product — and it is why nothing in this\n * folder has a default role list, a default action list, or an opinion about\n * what \"admin\" means.\n *\n * The split the screen is built around:\n *\n * | cortena-auth owns | the extension owns |\n * | --- | --- |\n * | who the user is, their org membership and status | every role it recognises |\n * | the groups, their names and their membership | the roles × actions matrix |\n * | the licence | which principal holds which role |\n *\n * So the person half of the members list is read-only here, the group half is\n * read-only too, and the roles beside them are the only editable thing.\n *\n * Where the groups come from is deliberately invisible: an extension asks\n * `api.groups` for `{ id, name, memberCount }` and never learns whether that\n * is a directory sync, an SCIM push or a hand-made list in cortena-auth.\n */\n\nimport type { TableQuery, TableResult } from \"@/components/data-table\";\n\n/**\n * A role is assigned to a principal, never to a user id (DESIGN-D22, §13.4).\n * Both kinds are edited on the same screen and through the same dialog; only\n * the method underneath differs.\n */\nexport interface AdminPrincipal {\n kind: \"user\" | \"group\";\n /** cortena-auth user id, or cortena-auth group id. */\n id: string;\n /** Display name, for the dialog title. */\n name?: string;\n}\n\n/** A group, as a member's row refers to it. Defined and maintained in cortena-auth. */\nexport interface AdminGroupRef {\n id: string;\n name: string;\n}\n\n/**\n * Why a member holds an effective role: assigned to them directly, or carried\n * by a group they belong to. Never dropped from the rendering — an\n * administrator who cannot see *why* somebody has a permission removes the\n * wrong assignment and believes the job is done (P-43).\n */\nexport type AdminRoleSource = \"direct\" | { group: AdminGroupRef };\n\n/** One entry of `effectiveRoles`: the role, and the assignment that granted it. */\nexport interface AdminEffectiveRole {\n role: string;\n source: AdminRoleSource;\n}\n\n/**\n * A row of `members.list`. A join: the person and their groups are\n * cortena-auth's and read-only; `extensionRoles` is the extension's own direct\n * assignment; `effectiveRoles` is the per-request resolution of direct +\n * inherited that `may()` performs, rendered — never a stored table (P-43).\n */\nexport interface AdminMemberRow {\n userId: string;\n name: string;\n email: string;\n status: string;\n /** ISO timestamp, or null when the user has never signed in. */\n lastActiveAt: string | null;\n groups: AdminGroupRef[];\n extensionRoles: string[];\n effectiveRoles: AdminEffectiveRole[];\n}\n\n/**\n * A row of `groups.list` — read-only as to the group itself. The roles a group\n * carries are the extension's and come back separately from `groups.roles()`,\n * because the group list is identity's and the assignment map is not.\n */\nexport interface AdminGroupRow {\n id: string;\n name: string;\n memberCount: number;\n}\n\n/** `licence.get` — a read-through display of cortena-auth's licence. */\nexport interface AdminLicence {\n plan: string;\n seats: number | null;\n seatsUsed: number | null;\n features: string[];\n}\n\n/**\n * `permissions.get` / `permissions.put` — the extension's own matrix, in the\n * extension's own database. This is the source of truth that both the REST\n * routes and the MCP tools enforce through one `may()` (§13.2, P-14).\n */\nexport interface AdminPermissionMatrix {\n roles: string[];\n actions: string[];\n /** role → the actions it grants. A role absent from the map grants nothing. */\n grants: Record<string, string[]>;\n}\n\n/**\n * Everything the composition talks to, one namespace per resource in §13.3.\n *\n * `groups` is the only optional member, and it is optional for a reason rather\n * than for convenience: an extension whose cortena-auth tenant has no groups\n * omits it, and every group affordance disappears instead of rendering empty.\n * It disappears equally when `groups.list()` comes back empty — an empty\n * \"Groups\" section reads as a broken feature, and there is nothing an\n * administrator can do about it from here.\n */\nexport interface AdminPermissionsApi {\n members: {\n /** `GET .../admin/members` — the DataTable server source. */\n list: (query: TableQuery, signal?: AbortSignal) => Promise<TableResult<AdminMemberRow>>;\n /** `PATCH .../admin/members/:userId { extensionRoles }` — direct assignment only. */\n setRoles: (userId: string, roles: string[]) => Promise<void>;\n };\n groups?: {\n /** `GET .../admin/groups` — read-only; the whole list, small enough to be a client source. */\n list: (signal?: AbortSignal) => Promise<AdminGroupRow[]>;\n /** The extension's assignment: group id → the roles that group carries. */\n roles: (signal?: AbortSignal) => Promise<Record<string, string[]>>;\n /** `PATCH .../admin/groups/:groupId { extensionRoles }` */\n setRoles: (groupId: string, roles: string[]) => Promise<void>;\n };\n licence: {\n /** `GET .../admin/licence` — read-only, always. */\n get: (signal?: AbortSignal) => Promise<AdminLicence>;\n };\n permissions: {\n /** `GET .../admin/permissions` */\n get: (signal?: AbortSignal) => Promise<AdminPermissionMatrix>;\n /** `PUT .../admin/permissions` */\n put: (\n matrix: AdminPermissionMatrix,\n signal?: AbortSignal,\n ) => Promise<AdminPermissionMatrix | void>;\n };\n}\n\n/** The four screens, named after their paths under `admin/`. */\nexport type AdminRoute = \"members\" | \"roles\" | \"licence\" | \"permissions\";\n\nexport const ADMIN_ROUTES: readonly AdminRoute[] = [\n \"members\",\n \"roles\",\n \"licence\",\n \"permissions\",\n];\n\nexport const ADMIN_ROUTE_LABELS: Record<AdminRoute, string> = {\n members: \"Members\",\n roles: \"Roles\",\n licence: \"Licence\",\n permissions: \"Permissions\",\n};\n\n/**\n * The broker's error envelope (§15.6). A failed mutation must show what the\n * server said — \"assigneeId: Required\" and \"Invalid input\" cost the same to\n * produce and differ by a whole round-trip — so the rejection is unwrapped\n * rather than replaced with a house message.\n */\nexport interface AdminErrorEnvelope {\n ok: false;\n status?: number;\n code?: string;\n message: string;\n details?: unknown;\n}\n\nfunction envelopeMessage(value: unknown): string | null {\n if (!value || typeof value !== \"object\") return null;\n const message = (value as { message?: unknown }).message;\n return typeof message === \"string\" && message.length > 0 ? message : null;\n}\n\n/**\n * The message to show for a rejection.\n *\n * A fetch wrapper usually rejects with an `Error` and hangs the parsed\n * envelope off it, so the envelope is preferred over the generic\n * `\"HTTP 403\"` the Error itself carries. A rejection that *is* the envelope,\n * and a plain `Error`, both work as well.\n */\nexport function adminErrorMessage(error: unknown): string {\n if (error && typeof error === \"object\") {\n const carrier = error as { envelope?: unknown; body?: unknown; error?: unknown };\n const nested =\n envelopeMessage(carrier.envelope) ??\n envelopeMessage(carrier.body) ??\n envelopeMessage(carrier.error);\n if (nested) return nested;\n }\n if (error instanceof Error) return error.message || \"Something went wrong\";\n const direct = envelopeMessage(error);\n if (direct) return direct;\n return String(error);\n}\n\n/** Tolerates a server that omits an array the contract says is required. */\nexport function groupsOf(row: AdminMemberRow): AdminGroupRef[] {\n return row.groups ?? [];\n}\n\n/**\n * The effective roles to render. When the server sends none, the direct\n * assignments are shown as direct — which is what they are — rather than the\n * screen inventing an inheritance it cannot know about.\n */\nexport function effectiveRolesOf(row: AdminMemberRow): AdminEffectiveRole[] {\n if (row.effectiveRoles?.length) return row.effectiveRoles;\n return (row.extensionRoles ?? []).map((role) => ({ role, source: \"direct\" as const }));\n}\n\n/** The group behind an effective role, or `null` for a direct assignment. */\nexport function sourceGroupOf(source: AdminRoleSource): AdminGroupRef | null {\n if (source === \"direct\" || !source) return null;\n return source.group ?? null;\n}\n"],"mappings":";;AA0JA,MAAa,eAAsC;CACjD;CACA;CACA;CACA;AACF;AAEA,MAAa,qBAAiD;CAC5D,SAAS;CACT,OAAO;CACP,SAAS;CACT,aAAa;AACf;AAgBA,SAAS,gBAAgB,OAA+B;CACtD,IAAI,CAAC,SAAS,OAAO,UAAU,UAAU,OAAO;CAChD,MAAM,UAAW,MAAgC;CACjD,OAAO,OAAO,YAAY,YAAY,QAAQ,SAAS,IAAI,UAAU;AACvE;;;;;;;;;AAUA,SAAgB,kBAAkB,OAAwB;CACxD,IAAI,SAAS,OAAO,UAAU,UAAU;EACtC,MAAM,UAAU;EAChB,MAAM,SACJ,gBAAgB,QAAQ,QAAQ,KAChC,gBAAgB,QAAQ,IAAI,KAC5B,gBAAgB,QAAQ,KAAK;EAC/B,IAAI,QAAQ,OAAO;CACrB;CACA,IAAI,iBAAiB,OAAO,OAAO,MAAM,WAAW;CACpD,MAAM,SAAS,gBAAgB,KAAK;CACpC,IAAI,QAAQ,OAAO;CACnB,OAAO,OAAO,KAAK;AACrB;;AAGA,SAAgB,SAAS,KAAsC;CAC7D,OAAO,IAAI,UAAU,CAAC;AACxB;;;;;;AAOA,SAAgB,iBAAiB,KAA2C;CAC1E,IAAI,IAAI,gBAAgB,QAAQ,OAAO,IAAI;CAC3C,QAAQ,IAAI,kBAAkB,CAAC,EAAA,CAAG,KAAK,UAAU;EAAE;EAAM,QAAQ;CAAkB,EAAE;AACvF;;AAGA,SAAgB,cAAc,QAA+C;CAC3E,IAAI,WAAW,YAAY,CAAC,QAAQ,OAAO;CAC3C,OAAO,OAAO,SAAS;AACzB"}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { AgentChatClient } from "../agent-chat/types.js";
|
|
3
|
+
import { AgentChatProps } from "./agent-chat.js";
|
|
4
|
+
import * as React from "react";
|
|
5
|
+
//#region src/components/agent-chat-popup.d.ts
|
|
6
|
+
export type AgentChatPopupState = "pill" | "panel" | "full";
|
|
7
|
+
export interface AgentChatPopupExtension {
|
|
8
|
+
/** Registered mark id — the same `x-cortena.icon` the shell draws. */
|
|
9
|
+
id: string;
|
|
10
|
+
/** Display name. Also the agent template slug, unless `agentId` overrides it. */
|
|
11
|
+
name: string;
|
|
12
|
+
}
|
|
13
|
+
export interface AgentChatPopupProps extends Omit<AgentChatProps, "agentName" | "storageScope" | "className" | "headerSlot" | "client"> {
|
|
14
|
+
extension: AgentChatPopupExtension;
|
|
15
|
+
/** The transport. Its `agentId` is the template slug the sessions belong to. */
|
|
16
|
+
client: AgentChatClient;
|
|
17
|
+
/**
|
|
18
|
+
* Names the `sessionStorage` slots. Defaults to the extension id, which is
|
|
19
|
+
* what keeps two extensions on one origin out of each other's sessions.
|
|
20
|
+
*/
|
|
21
|
+
storageScope?: string;
|
|
22
|
+
/** Start here instead of collapsed; the stored state still wins on reload. */
|
|
23
|
+
defaultState?: AgentChatPopupState;
|
|
24
|
+
className?: string;
|
|
25
|
+
}
|
|
26
|
+
declare function AgentChatPopup({ extension, client, storageScope, defaultState, className, ...chatProps }: AgentChatPopupProps): React.JSX.Element;
|
|
27
|
+
//#endregion
|
|
28
|
+
export { AgentChatPopup };
|
|
29
|
+
//# sourceMappingURL=agent-chat-popup.d.ts.map
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
"use client";
|
|
3
|
+
import { cn } from "../lib/cn.js";
|
|
4
|
+
import { BrandMark } from "./app-shell.js";
|
|
5
|
+
import { Button } from "./button.js";
|
|
6
|
+
import { viewStorageKey, windowSessionStorage } from "../agent-chat/session.js";
|
|
7
|
+
import { AgentChat } from "./agent-chat.js";
|
|
8
|
+
import { Maximize2, Minimize2, X } from "lucide-react";
|
|
9
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
10
|
+
import * as React from "react";
|
|
11
|
+
//#region src/components/agent-chat-popup.tsx
|
|
12
|
+
/**
|
|
13
|
+
* AgentChatPopup — the extension's own agent, in the corner of every screen.
|
|
14
|
+
*
|
|
15
|
+
* §17 of how-to-create-a-cortena-extension, audit rule P-25. Mounted in
|
|
16
|
+
* `AppShell`'s `agentSlot`, so it stacks above the theme/help cluster rather
|
|
17
|
+
* than beside it and no extension positions a floating control itself.
|
|
18
|
+
*
|
|
19
|
+
* ```tsx
|
|
20
|
+
* <AppShell
|
|
21
|
+
* extension={{ id: "tasks", name: "Tasks" }}
|
|
22
|
+
* user={user}
|
|
23
|
+
* agentSlot={
|
|
24
|
+
* <AgentChatPopup
|
|
25
|
+
* extension={{ id: "tasks", name: "Tasks" }}
|
|
26
|
+
* client={client} // createAguiAgentChatClient({ …, agentId: "tasks" })
|
|
27
|
+
* />
|
|
28
|
+
* }
|
|
29
|
+
* />
|
|
30
|
+
* ```
|
|
31
|
+
*
|
|
32
|
+
* Three states, and the transitions between them are fixed: a collapsed pill,
|
|
33
|
+
* a panel, and full screen. **Cmd/Ctrl+J** toggles between the pill and the
|
|
34
|
+
* panel; **Escape** steps full screen -> panel -> pill, except while a run is
|
|
35
|
+
* streaming, where the composer takes Escape to mean "stop" — the first Escape
|
|
36
|
+
* during a run is nearly always about the run, not about the window.
|
|
37
|
+
*
|
|
38
|
+
* The chat stays mounted in every state, hidden rather than unmounted while
|
|
39
|
+
* collapsed. That is not an optimisation: a run started before the user
|
|
40
|
+
* collapsed the pop-up keeps streaming into it, and the unread dot on the pill
|
|
41
|
+
* is what says so. Unmounting would throw the reply away halfway through.
|
|
42
|
+
*
|
|
43
|
+
* State is remembered per window, in `sessionStorage`, alongside the session
|
|
44
|
+
* key — a second window of the same extension opens its own chat on its own
|
|
45
|
+
* session rather than inheriting this one's.
|
|
46
|
+
*/
|
|
47
|
+
/** Cmd on a Mac, Ctrl elsewhere. Documented in README.md. */
|
|
48
|
+
function isToggleShortcut(event) {
|
|
49
|
+
return (event.metaKey || event.ctrlKey) && !event.altKey && event.key.toLowerCase() === "j";
|
|
50
|
+
}
|
|
51
|
+
function AgentChatPopup({ extension, client, storageScope, defaultState = "pill", className, ...chatProps }) {
|
|
52
|
+
const scope = storageScope ?? extension.id;
|
|
53
|
+
const [state, setState] = React.useState(defaultState);
|
|
54
|
+
const [unread, setUnread] = React.useState(false);
|
|
55
|
+
const stateRef = React.useRef(state);
|
|
56
|
+
stateRef.current = state;
|
|
57
|
+
const pillRef = React.useRef(null);
|
|
58
|
+
const panelRef = React.useRef(null);
|
|
59
|
+
/** Whether the user has opened or closed it, as against it starting open. */
|
|
60
|
+
const moved = React.useRef(false);
|
|
61
|
+
React.useEffect(() => {
|
|
62
|
+
try {
|
|
63
|
+
const stored = windowSessionStorage()?.getItem(viewStorageKey(scope));
|
|
64
|
+
if (stored === "pill" || stored === "panel" || stored === "full") setState(stored);
|
|
65
|
+
} catch {}
|
|
66
|
+
}, [scope]);
|
|
67
|
+
const go = React.useCallback((next) => {
|
|
68
|
+
moved.current = true;
|
|
69
|
+
setState(next);
|
|
70
|
+
if (next !== "pill") setUnread(false);
|
|
71
|
+
try {
|
|
72
|
+
windowSessionStorage()?.setItem(viewStorageKey(scope), next);
|
|
73
|
+
} catch {}
|
|
74
|
+
}, [scope]);
|
|
75
|
+
React.useEffect(() => {
|
|
76
|
+
return client.onChat((event) => {
|
|
77
|
+
if (stateRef.current === "pill" && (event.state === "delta" || event.state === "final")) setUnread(true);
|
|
78
|
+
});
|
|
79
|
+
}, [client]);
|
|
80
|
+
React.useEffect(() => {
|
|
81
|
+
const onKeyDown = (event) => {
|
|
82
|
+
if (!isToggleShortcut(event)) return;
|
|
83
|
+
event.preventDefault();
|
|
84
|
+
go(stateRef.current === "pill" ? "panel" : "pill");
|
|
85
|
+
};
|
|
86
|
+
window.addEventListener("keydown", onKeyDown);
|
|
87
|
+
return () => window.removeEventListener("keydown", onKeyDown);
|
|
88
|
+
}, [go]);
|
|
89
|
+
/**
|
|
90
|
+
* Escape steps down one level. It is handled on the panel rather than on the
|
|
91
|
+
* window so it only fires when focus is inside the pop-up, and it arrives
|
|
92
|
+
* here only when the composer did not claim it — the composer stops
|
|
93
|
+
* propagation while a run is streaming, where Escape means "stop the run".
|
|
94
|
+
*/
|
|
95
|
+
const onKeyDown = React.useCallback((event) => {
|
|
96
|
+
if (event.key !== "Escape") return;
|
|
97
|
+
event.preventDefault();
|
|
98
|
+
go(stateRef.current === "full" ? "panel" : "pill");
|
|
99
|
+
}, [go]);
|
|
100
|
+
const open = state !== "pill";
|
|
101
|
+
/**
|
|
102
|
+
* Focus follows the pop-up.
|
|
103
|
+
*
|
|
104
|
+
* The panel is `hidden`, not unmounted, so nothing moves focus by itself.
|
|
105
|
+
* Opening left focus on the pill — which is now `hidden`, so the browser
|
|
106
|
+
* resets it to <body> and the next Tab starts at the top of the page rather
|
|
107
|
+
* than in the chat that was just opened. Closing had the mirror problem:
|
|
108
|
+
* focus was inside a subtree that had just become hidden.
|
|
109
|
+
*
|
|
110
|
+
* Only after the user moves it. A pop-up that starts on `panel`, or one
|
|
111
|
+
* restored from `sessionStorage` on load, must not steal focus from the page.
|
|
112
|
+
*/
|
|
113
|
+
React.useEffect(() => {
|
|
114
|
+
if (!moved.current) return;
|
|
115
|
+
const frame = requestAnimationFrame(() => {
|
|
116
|
+
if (open) panelRef.current?.querySelector("[data-slot=\"agent-chat-input\"]")?.focus({ preventScroll: true });
|
|
117
|
+
else pillRef.current?.focus({ preventScroll: true });
|
|
118
|
+
});
|
|
119
|
+
return () => cancelAnimationFrame(frame);
|
|
120
|
+
}, [open]);
|
|
121
|
+
return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsxs("button", {
|
|
122
|
+
ref: pillRef,
|
|
123
|
+
type: "button",
|
|
124
|
+
"data-slot": "agent-chat-pill",
|
|
125
|
+
"data-state": state,
|
|
126
|
+
"data-unread": unread ? "" : void 0,
|
|
127
|
+
"aria-expanded": open,
|
|
128
|
+
"aria-label": `Ask ${extension.name}`,
|
|
129
|
+
hidden: open,
|
|
130
|
+
onClick: () => go("panel"),
|
|
131
|
+
className: cn("relative inline-flex h-10 items-center gap-2 rounded-[var(--ds-radius-full)] px-3.5", "border border-[var(--ds-border)] bg-[var(--ds-card)] text-[color:var(--ds-foreground)]", "shadow-[var(--ds-shadow-md)] outline-none", "transition-colors duration-[var(--ds-duration-fast)] ease-[var(--ds-ease-out)]", "hover:bg-[var(--ds-hover)]", "focus-visible:ring-2 focus-visible:ring-[var(--ds-ring)] focus-visible:ring-offset-2", "focus-visible:ring-offset-[var(--ds-background)]", className),
|
|
132
|
+
children: [
|
|
133
|
+
/* @__PURE__ */ jsx("span", {
|
|
134
|
+
className: "flex size-5 items-center justify-center text-[color:var(--ds-primary)]",
|
|
135
|
+
children: /* @__PURE__ */ jsx(BrandMark, {
|
|
136
|
+
id: extension.id,
|
|
137
|
+
name: extension.name,
|
|
138
|
+
size: 16,
|
|
139
|
+
decorative: true
|
|
140
|
+
})
|
|
141
|
+
}),
|
|
142
|
+
/* @__PURE__ */ jsxs("span", {
|
|
143
|
+
className: "text-[length:var(--ds-text-caption-lg)] font-medium",
|
|
144
|
+
children: ["Ask ", extension.name]
|
|
145
|
+
}),
|
|
146
|
+
unread ? /* @__PURE__ */ jsx("span", {
|
|
147
|
+
"data-slot": "agent-chat-pill-unread",
|
|
148
|
+
"aria-label": "The agent is replying",
|
|
149
|
+
className: "size-2 rounded-[var(--ds-radius-full)] bg-[var(--ds-primary)]"
|
|
150
|
+
}) : null
|
|
151
|
+
]
|
|
152
|
+
}), /* @__PURE__ */ jsx("div", {
|
|
153
|
+
ref: panelRef,
|
|
154
|
+
"data-slot": "agent-chat-panel",
|
|
155
|
+
"data-state": state,
|
|
156
|
+
role: "dialog",
|
|
157
|
+
"aria-label": `${extension.name} agent`,
|
|
158
|
+
hidden: !open,
|
|
159
|
+
onKeyDown,
|
|
160
|
+
className: cn("fixed z-50 flex flex-col overflow-hidden border border-[var(--ds-border)]", "bg-[var(--ds-card)] shadow-[var(--ds-shadow-lg)]", state === "full" ? "inset-0 rounded-none" : cn("right-6 bottom-6 rounded-[var(--ds-radius-lg)]", "h-[min(40rem,calc(100vh-3rem))] w-[min(26.25rem,calc(100vw-3rem))]")),
|
|
161
|
+
children: /* @__PURE__ */ jsx(AgentChat, {
|
|
162
|
+
client,
|
|
163
|
+
agentName: extension.name,
|
|
164
|
+
storageScope: scope,
|
|
165
|
+
className: "min-h-0 flex-1",
|
|
166
|
+
headerSlot: /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(Button, {
|
|
167
|
+
size: "icon-sm",
|
|
168
|
+
variant: "ghost",
|
|
169
|
+
"data-slot": "agent-chat-expand",
|
|
170
|
+
"aria-label": state === "full" ? "Restore the panel" : "Expand to full screen",
|
|
171
|
+
onClick: () => go(state === "full" ? "panel" : "full"),
|
|
172
|
+
children: state === "full" ? /* @__PURE__ */ jsx(Minimize2, { "aria-hidden": true }) : /* @__PURE__ */ jsx(Maximize2, { "aria-hidden": true })
|
|
173
|
+
}), /* @__PURE__ */ jsx(Button, {
|
|
174
|
+
size: "icon-sm",
|
|
175
|
+
variant: "ghost",
|
|
176
|
+
"data-slot": "agent-chat-collapse",
|
|
177
|
+
"aria-label": "Collapse the agent",
|
|
178
|
+
onClick: () => go("pill"),
|
|
179
|
+
children: /* @__PURE__ */ jsx(X, { "aria-hidden": true })
|
|
180
|
+
})] }),
|
|
181
|
+
...chatProps
|
|
182
|
+
})
|
|
183
|
+
})] });
|
|
184
|
+
}
|
|
185
|
+
//#endregion
|
|
186
|
+
export { AgentChatPopup };
|
|
187
|
+
|
|
188
|
+
//# sourceMappingURL=agent-chat-popup.js.map
|