cortena-ui 1.4.2 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +107 -0
- package/LICENSE +7 -0
- package/README.md +235 -3
- package/dist/a2ui/views.js +2 -2
- package/dist/agent-chat/a2ui-block.d.ts +60 -0
- package/dist/agent-chat/a2ui-block.js +69 -0
- package/dist/agent-chat/a2ui-block.js.map +1 -0
- package/dist/agent-chat/agui-client.d.ts +40 -0
- package/dist/agent-chat/agui-client.js +251 -0
- package/dist/agent-chat/agui-client.js.map +1 -0
- package/dist/agent-chat/bridge.d.ts +109 -0
- package/dist/agent-chat/bridge.js +353 -0
- package/dist/agent-chat/bridge.js.map +1 -0
- package/dist/agent-chat/session.d.ts +79 -0
- package/dist/agent-chat/session.js +391 -0
- package/dist/agent-chat/session.js.map +1 -0
- package/dist/agent-chat/step-label.d.ts +99 -0
- package/dist/agent-chat/step-label.js +116 -0
- package/dist/agent-chat/step-label.js.map +1 -0
- package/dist/agent-chat/store.d.ts +102 -0
- package/dist/agent-chat/store.js +876 -0
- package/dist/agent-chat/store.js.map +1 -0
- package/dist/agent-chat/types.d.ts +277 -0
- package/dist/agent-chat/types.js +17 -0
- package/dist/agent-chat/types.js.map +1 -0
- package/dist/agent-chat.d.ts +11 -0
- package/dist/agent-chat.js +11 -0
- package/dist/components/admin-permissions/admin-permissions.d.ts +66 -0
- package/dist/components/admin-permissions/admin-permissions.js +101 -0
- package/dist/components/admin-permissions/admin-permissions.js.map +1 -0
- package/dist/components/admin-permissions/context.d.ts +70 -0
- package/dist/components/admin-permissions/context.js +258 -0
- package/dist/components/admin-permissions/context.js.map +1 -0
- package/dist/components/admin-permissions/index.d.ts +10 -0
- package/dist/components/admin-permissions/licence.d.ts +15 -0
- package/dist/components/admin-permissions/licence.js +78 -0
- package/dist/components/admin-permissions/licence.js.map +1 -0
- package/dist/components/admin-permissions/matrix.d.ts +20 -0
- package/dist/components/admin-permissions/matrix.js +191 -0
- package/dist/components/admin-permissions/matrix.js.map +1 -0
- package/dist/components/admin-permissions/members.d.ts +18 -0
- package/dist/components/admin-permissions/members.js +185 -0
- package/dist/components/admin-permissions/members.js.map +1 -0
- package/dist/components/admin-permissions/role-assignment.d.ts +35 -0
- package/dist/components/admin-permissions/role-assignment.js +174 -0
- package/dist/components/admin-permissions/role-assignment.js.map +1 -0
- package/dist/components/admin-permissions/roles.d.ts +25 -0
- package/dist/components/admin-permissions/roles.js +168 -0
- package/dist/components/admin-permissions/roles.js.map +1 -0
- package/dist/components/admin-permissions/types.d.ts +152 -0
- package/dist/components/admin-permissions/types.js +63 -0
- package/dist/components/admin-permissions/types.js.map +1 -0
- package/dist/components/agent-chat-popup.d.ts +29 -0
- package/dist/components/agent-chat-popup.js +188 -0
- package/dist/components/agent-chat-popup.js.map +1 -0
- package/dist/components/agent-chat.d.ts +163 -0
- package/dist/components/agent-chat.js +673 -0
- package/dist/components/agent-chat.js.map +1 -0
- package/dist/components/app-shell.d.ts +126 -0
- package/dist/components/app-shell.js +297 -0
- package/dist/components/app-shell.js.map +1 -0
- package/dist/components/badge.d.ts +1 -1
- package/dist/components/button-link.js +1 -1
- package/dist/components/button.d.ts +1 -1
- package/dist/components/checkbox.d.ts +1 -1
- package/dist/components/combobox.d.ts +1 -1
- package/dist/components/combobox.js +1 -1
- package/dist/components/consent-screen.d.ts +65 -0
- package/dist/components/consent-screen.js +123 -0
- package/dist/components/consent-screen.js.map +1 -0
- package/dist/components/data-table/data-table.d.ts +15 -1
- package/dist/components/data-table/data-table.js +18 -4
- package/dist/components/data-table/data-table.js.map +1 -1
- package/dist/components/data-table/index.d.ts +4 -4
- package/dist/components/data-table/parts.d.ts +27 -3
- package/dist/components/data-table/parts.js +175 -55
- package/dist/components/data-table/parts.js.map +1 -1
- package/dist/components/data-table/types.d.ts +61 -0
- package/dist/components/data-table/use-data-table.js +91 -6
- package/dist/components/data-table/use-data-table.js.map +1 -1
- package/dist/components/data-table/use-server-source.js +119 -28
- package/dist/components/data-table/use-server-source.js.map +1 -1
- package/dist/components/help-panel.d.ts +131 -0
- package/dist/components/help-panel.js +545 -0
- package/dist/components/help-panel.js.map +1 -0
- package/dist/components/login-screen.d.ts +127 -0
- package/dist/components/login-screen.js +339 -0
- package/dist/components/login-screen.js.map +1 -0
- package/dist/components/session-guard.d.ts +268 -0
- package/dist/components/session-guard.js +632 -0
- package/dist/components/session-guard.js.map +1 -0
- package/dist/components/toast.d.ts +1 -1
- package/dist/core.d.ts +5 -1
- package/dist/core.js +11 -7
- package/dist/data-table.d.ts +13 -4
- package/dist/data-table.js +10 -2
- package/dist/hooks/use-cortena-theme.js +49 -3
- package/dist/hooks/use-cortena-theme.js.map +1 -1
- package/dist/index.d.ts +17 -4
- package/dist/index.js +21 -8
- package/dist/markdown.d.ts +2 -1
- package/dist/markdown.js +2 -1
- package/package.json +18 -5
- package/src/agent-chat/a2ui-block.ts +118 -0
- package/src/agent-chat/agui-client.ts +405 -0
- package/src/agent-chat/bridge.ts +445 -0
- package/src/agent-chat/session.ts +549 -0
- package/src/agent-chat/step-label.ts +177 -0
- package/src/agent-chat/store.ts +1234 -0
- package/src/agent-chat/types.ts +308 -0
- package/src/components/admin-permissions/admin-permissions.tsx +130 -0
- package/src/components/admin-permissions/context.tsx +376 -0
- package/src/components/admin-permissions/index.tsx +32 -0
- package/src/components/admin-permissions/licence.tsx +84 -0
- package/src/components/admin-permissions/matrix.tsx +257 -0
- package/src/components/admin-permissions/members.tsx +204 -0
- package/src/components/admin-permissions/role-assignment.tsx +239 -0
- package/src/components/admin-permissions/roles.tsx +169 -0
- package/src/components/admin-permissions/types.ts +231 -0
- package/src/components/agent-chat-popup.tsx +289 -0
- package/src/components/agent-chat.tsx +1006 -0
- package/src/components/app-shell.tsx +502 -0
- package/src/components/consent-screen.tsx +239 -0
- package/src/components/data-table/data-table.tsx +36 -0
- package/src/components/data-table/index.tsx +6 -1
- package/src/components/data-table/parts.tsx +223 -47
- package/src/components/data-table/types.ts +68 -0
- package/src/components/data-table/use-data-table.ts +152 -4
- package/src/components/data-table/use-server-source.ts +150 -12
- package/src/components/help-panel.tsx +765 -0
- package/src/components/login-screen.tsx +479 -0
- package/src/components/session-guard.tsx +1071 -0
- package/src/entries/agent-chat.ts +137 -0
- package/src/entries/core.ts +8 -0
- package/src/entries/data-table.ts +41 -0
- package/src/entries/markdown.ts +25 -0
- package/src/hooks/use-cortena-theme.ts +63 -4
- package/src/index.ts +6 -0
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
"use client";
|
|
3
|
+
import { cn } from "../../lib/cn.js";
|
|
4
|
+
import { Button } from "../button.js";
|
|
5
|
+
import { Spinner } from "../spinner.js";
|
|
6
|
+
import { Checkbox } from "../checkbox.js";
|
|
7
|
+
import { Chip } from "../chip.js";
|
|
8
|
+
import { Dialog, DialogBody, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../dialog.js";
|
|
9
|
+
import { ErrorBanner } from "../error-banner.js";
|
|
10
|
+
import { adminErrorMessage, sourceGroupOf } from "./types.js";
|
|
11
|
+
import { useAdminPermissions } from "./context.js";
|
|
12
|
+
import { Users } from "lucide-react";
|
|
13
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
14
|
+
import * as React from "react";
|
|
15
|
+
//#region src/components/admin-permissions/role-assignment.tsx
|
|
16
|
+
/**
|
|
17
|
+
* The effective-roles cell. Every role carries the assignment that granted it:
|
|
18
|
+
* nothing for a direct one, and the group's name for an inherited one. This is
|
|
19
|
+
* the whole point of §13.4 and P-43 — an administrator who cannot see why
|
|
20
|
+
* somebody has a permission removes the wrong assignment and believes the job
|
|
21
|
+
* is done.
|
|
22
|
+
*/
|
|
23
|
+
function EffectiveRoles({ roles, className }) {
|
|
24
|
+
if (roles.length === 0) return /* @__PURE__ */ jsx("span", {
|
|
25
|
+
"data-slot": "admin-effective-roles-empty",
|
|
26
|
+
className: "text-[length:var(--ds-text-caption)] text-[color:var(--ds-text-tertiary)]",
|
|
27
|
+
children: "No roles"
|
|
28
|
+
});
|
|
29
|
+
return /* @__PURE__ */ jsx("div", {
|
|
30
|
+
"data-slot": "admin-effective-roles",
|
|
31
|
+
className: cn("flex flex-wrap items-center gap-1", className),
|
|
32
|
+
children: roles.map((entry, index) => {
|
|
33
|
+
const group = sourceGroupOf(entry.source);
|
|
34
|
+
return /* @__PURE__ */ jsxs(Chip, {
|
|
35
|
+
size: "sm",
|
|
36
|
+
variant: group ? "outline" : "accent",
|
|
37
|
+
"data-role": entry.role,
|
|
38
|
+
"data-source": group ? group.id : "direct",
|
|
39
|
+
title: group ? `${entry.role} — via group ${group.name}` : `${entry.role} — assigned directly`,
|
|
40
|
+
children: [entry.role, group ? /* @__PURE__ */ jsxs("span", {
|
|
41
|
+
className: "inline-flex items-center gap-1 text-[color:var(--ds-text-tertiary)]",
|
|
42
|
+
children: [
|
|
43
|
+
/* @__PURE__ */ jsx(Users, { "aria-hidden": true }),
|
|
44
|
+
"via ",
|
|
45
|
+
group.name
|
|
46
|
+
]
|
|
47
|
+
}) : null]
|
|
48
|
+
}, `${entry.role}-${group?.id ?? "direct"}-${index}`);
|
|
49
|
+
})
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
/** A plain list of directly assigned roles, for the group list. */
|
|
53
|
+
function DirectRoles({ roles }) {
|
|
54
|
+
if (roles.length === 0) return /* @__PURE__ */ jsx("span", {
|
|
55
|
+
"data-slot": "admin-direct-roles-empty",
|
|
56
|
+
className: "text-[length:var(--ds-text-caption)] text-[color:var(--ds-text-tertiary)]",
|
|
57
|
+
children: "None"
|
|
58
|
+
});
|
|
59
|
+
return /* @__PURE__ */ jsx("div", {
|
|
60
|
+
"data-slot": "admin-direct-roles",
|
|
61
|
+
className: "flex flex-wrap items-center gap-1",
|
|
62
|
+
children: roles.map((role) => /* @__PURE__ */ jsx(Chip, {
|
|
63
|
+
size: "sm",
|
|
64
|
+
variant: "accent",
|
|
65
|
+
"data-role": role,
|
|
66
|
+
children: role
|
|
67
|
+
}, role))
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* The one editor for a role assignment, for both principal kinds (DESIGN-D22).
|
|
72
|
+
* It offers the roles the extension declared and nothing else; a role already
|
|
73
|
+
* assigned that the matrix no longer declares is still listed, marked, and
|
|
74
|
+
* removable, because hiding it would leave a grant nobody can see or revoke.
|
|
75
|
+
*/
|
|
76
|
+
function RoleAssignmentDialog({ principal, current, onOpenChange }) {
|
|
77
|
+
const { matrix, assignRoles } = useAdminPermissions();
|
|
78
|
+
const [selected, setSelected] = React.useState(current);
|
|
79
|
+
const [saving, setSaving] = React.useState(false);
|
|
80
|
+
const [error, setError] = React.useState(null);
|
|
81
|
+
const key = principal ? `${principal.kind}:${principal.id}` : null;
|
|
82
|
+
const [seededKey, setSeededKey] = React.useState(key);
|
|
83
|
+
if (key !== seededKey) {
|
|
84
|
+
setSeededKey(key);
|
|
85
|
+
setSelected(current);
|
|
86
|
+
setError(null);
|
|
87
|
+
}
|
|
88
|
+
const declared = matrix?.roles ?? [];
|
|
89
|
+
const retired = current.filter((role) => !declared.includes(role));
|
|
90
|
+
const options = [...declared, ...retired];
|
|
91
|
+
const toggle = (role, checked) => {
|
|
92
|
+
setSelected((prev) => checked ? [...prev, role] : prev.filter((r) => r !== role));
|
|
93
|
+
};
|
|
94
|
+
const save = async () => {
|
|
95
|
+
if (!principal) return;
|
|
96
|
+
setSaving(true);
|
|
97
|
+
setError(null);
|
|
98
|
+
try {
|
|
99
|
+
await assignRoles(principal, [...selected]);
|
|
100
|
+
onOpenChange(false);
|
|
101
|
+
} catch (err) {
|
|
102
|
+
setError(adminErrorMessage(err));
|
|
103
|
+
} finally {
|
|
104
|
+
setSaving(false);
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
return /* @__PURE__ */ jsx(Dialog, {
|
|
108
|
+
open: principal !== null,
|
|
109
|
+
onOpenChange: (open) => !open && onOpenChange(false),
|
|
110
|
+
children: /* @__PURE__ */ jsxs(DialogContent, {
|
|
111
|
+
"data-slot": "admin-role-dialog",
|
|
112
|
+
children: [
|
|
113
|
+
/* @__PURE__ */ jsxs(DialogHeader, { children: [/* @__PURE__ */ jsx(DialogTitle, { children: "Edit roles" }), /* @__PURE__ */ jsx(DialogDescription, { children: principal?.kind === "group" ? /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
114
|
+
"Every member of ",
|
|
115
|
+
/* @__PURE__ */ jsx("strong", { children: principal.name ?? principal.id }),
|
|
116
|
+
" inherits the roles you select here, as soon as they join."
|
|
117
|
+
] }) : /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
118
|
+
"Direct assignment for ",
|
|
119
|
+
/* @__PURE__ */ jsx("strong", { children: principal?.name ?? principal?.id }),
|
|
120
|
+
". Roles inherited from a group are changed on the group, not here."
|
|
121
|
+
] }) })] }),
|
|
122
|
+
/* @__PURE__ */ jsxs(DialogBody, {
|
|
123
|
+
className: "flex flex-col gap-2",
|
|
124
|
+
children: [error ? /* @__PURE__ */ jsx(ErrorBanner, {
|
|
125
|
+
message: error,
|
|
126
|
+
"data-slot": "admin-role-dialog-error"
|
|
127
|
+
}) : null, options.length === 0 ? /* @__PURE__ */ jsx("p", {
|
|
128
|
+
className: "text-[length:var(--ds-text-body-sm)] text-[color:var(--ds-muted-foreground)]",
|
|
129
|
+
children: "This extension has not declared any roles yet."
|
|
130
|
+
}) : /* @__PURE__ */ jsx("ul", {
|
|
131
|
+
className: "flex flex-col gap-0.5",
|
|
132
|
+
"data-slot": "admin-role-options",
|
|
133
|
+
children: options.map((role) => {
|
|
134
|
+
const checked = selected.includes(role);
|
|
135
|
+
return /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsxs("label", {
|
|
136
|
+
className: cn("flex cursor-pointer items-center gap-2.5 rounded-[var(--ds-radius-md)] px-2 py-1.5", "text-[length:var(--ds-text-body-sm)] text-[color:var(--ds-foreground)]", "transition-colors duration-[var(--ds-duration-fast)] ease-[var(--ds-ease-out)]", "hover:bg-[var(--ds-hover)]"),
|
|
137
|
+
children: [
|
|
138
|
+
/* @__PURE__ */ jsx(Checkbox, {
|
|
139
|
+
checked,
|
|
140
|
+
onCheckedChange: (next) => toggle(role, Boolean(next))
|
|
141
|
+
}),
|
|
142
|
+
/* @__PURE__ */ jsx("span", {
|
|
143
|
+
className: "flex-1",
|
|
144
|
+
children: role
|
|
145
|
+
}),
|
|
146
|
+
declared.includes(role) ? null : /* @__PURE__ */ jsx(Chip, {
|
|
147
|
+
size: "sm",
|
|
148
|
+
variant: "outline",
|
|
149
|
+
title: "Not in the declared vocabulary",
|
|
150
|
+
children: "retired"
|
|
151
|
+
})
|
|
152
|
+
]
|
|
153
|
+
}) }, role);
|
|
154
|
+
})
|
|
155
|
+
})]
|
|
156
|
+
}),
|
|
157
|
+
/* @__PURE__ */ jsxs(DialogFooter, { children: [/* @__PURE__ */ jsx(Button, {
|
|
158
|
+
variant: "outline",
|
|
159
|
+
onClick: () => onOpenChange(false),
|
|
160
|
+
disabled: saving,
|
|
161
|
+
children: "Cancel"
|
|
162
|
+
}), /* @__PURE__ */ jsxs(Button, {
|
|
163
|
+
onClick: save,
|
|
164
|
+
disabled: saving || options.length === 0,
|
|
165
|
+
children: [saving ? /* @__PURE__ */ jsx(Spinner, { tone: "inverse" }) : null, "Save roles"]
|
|
166
|
+
})] })
|
|
167
|
+
]
|
|
168
|
+
})
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
//#endregion
|
|
172
|
+
export { DirectRoles, EffectiveRoles, RoleAssignmentDialog };
|
|
173
|
+
|
|
174
|
+
//# sourceMappingURL=role-assignment.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"role-assignment.js","names":[],"sources":["../../../src/components/admin-permissions/role-assignment.tsx"],"sourcesContent":["\"use client\";\n\nimport { Users } from \"lucide-react\";\nimport * as React from \"react\";\nimport { Button } from \"@/components/button\";\nimport { Checkbox } from \"@/components/checkbox\";\nimport { Chip } from \"@/components/chip\";\nimport {\n Dialog,\n DialogBody,\n DialogContent,\n DialogDescription,\n DialogFooter,\n DialogHeader,\n DialogTitle,\n} from \"@/components/dialog\";\nimport { ErrorBanner } from \"@/components/error-banner\";\nimport { Spinner } from \"@/components/spinner\";\nimport { cn } from \"@/lib/cn\";\nimport { useAdminPermissions } from \"./context\";\nimport {\n adminErrorMessage,\n sourceGroupOf,\n type AdminEffectiveRole,\n type AdminPrincipal,\n} from \"./types\";\n\n/**\n * The effective-roles cell. Every role carries the assignment that granted it:\n * nothing for a direct one, and the group's name for an inherited one. This is\n * the whole point of §13.4 and P-43 — an administrator who cannot see why\n * somebody has a permission removes the wrong assignment and believes the job\n * is done.\n */\nexport function EffectiveRoles({\n roles,\n className,\n}: {\n roles: readonly AdminEffectiveRole[];\n className?: string;\n}) {\n if (roles.length === 0) {\n return (\n <span\n data-slot=\"admin-effective-roles-empty\"\n className=\"text-[length:var(--ds-text-caption)] text-[color:var(--ds-text-tertiary)]\"\n >\n No roles\n </span>\n );\n }\n return (\n <div\n data-slot=\"admin-effective-roles\"\n className={cn(\"flex flex-wrap items-center gap-1\", className)}\n >\n {roles.map((entry, index) => {\n const group = sourceGroupOf(entry.source);\n return (\n <Chip\n key={`${entry.role}-${group?.id ?? \"direct\"}-${index}`}\n size=\"sm\"\n variant={group ? \"outline\" : \"accent\"}\n data-role={entry.role}\n data-source={group ? group.id : \"direct\"}\n title={\n group\n ? `${entry.role} — via group ${group.name}`\n : `${entry.role} — assigned directly`\n }\n >\n {entry.role}\n {group ? (\n <span className=\"inline-flex items-center gap-1 text-[color:var(--ds-text-tertiary)]\">\n <Users aria-hidden />\n via {group.name}\n </span>\n ) : null}\n </Chip>\n );\n })}\n </div>\n );\n}\n\n/** A plain list of directly assigned roles, for the group list. */\nexport function DirectRoles({ roles }: { roles: readonly string[] }) {\n if (roles.length === 0) {\n return (\n <span\n data-slot=\"admin-direct-roles-empty\"\n className=\"text-[length:var(--ds-text-caption)] text-[color:var(--ds-text-tertiary)]\"\n >\n None\n </span>\n );\n }\n return (\n <div data-slot=\"admin-direct-roles\" className=\"flex flex-wrap items-center gap-1\">\n {roles.map((role) => (\n <Chip key={role} size=\"sm\" variant=\"accent\" data-role={role}>\n {role}\n </Chip>\n ))}\n </div>\n );\n}\n\nexport interface RoleAssignmentDialogProps {\n /** The principal being edited. A user and a group take the same dialog. */\n principal: AdminPrincipal | null;\n /** The principal's current direct assignment. */\n current: readonly string[];\n onOpenChange: (open: boolean) => void;\n}\n\n/**\n * The one editor for a role assignment, for both principal kinds (DESIGN-D22).\n * It offers the roles the extension declared and nothing else; a role already\n * assigned that the matrix no longer declares is still listed, marked, and\n * removable, because hiding it would leave a grant nobody can see or revoke.\n */\nexport function RoleAssignmentDialog({\n principal,\n current,\n onOpenChange,\n}: RoleAssignmentDialogProps) {\n const { matrix, assignRoles } = useAdminPermissions();\n const [selected, setSelected] = React.useState<readonly string[]>(current);\n const [saving, setSaving] = React.useState(false);\n const [error, setError] = React.useState<string | null>(null);\n\n // Re-seed whenever a different principal is opened — the documented\n // adjust-state-during-render pattern, not an effect, so the dialog never\n // paints one principal's roles under another principal's name.\n const key = principal ? `${principal.kind}:${principal.id}` : null;\n const [seededKey, setSeededKey] = React.useState<string | null>(key);\n if (key !== seededKey) {\n setSeededKey(key);\n setSelected(current);\n setError(null);\n }\n\n const declared = matrix?.roles ?? [];\n const retired = current.filter((role) => !declared.includes(role));\n const options = [...declared, ...retired];\n\n const toggle = (role: string, checked: boolean) => {\n setSelected((prev) => (checked ? [...prev, role] : prev.filter((r) => r !== role)));\n };\n\n const save = async () => {\n if (!principal) return;\n setSaving(true);\n setError(null);\n try {\n await assignRoles(principal, [...selected]);\n onOpenChange(false);\n } catch (err) {\n // The envelope's own message, not a house one (§15.6). The optimistic\n // write has already been rolled back by `assignRoles`.\n setError(adminErrorMessage(err));\n } finally {\n setSaving(false);\n }\n };\n\n return (\n <Dialog open={principal !== null} onOpenChange={(open) => !open && onOpenChange(false)}>\n <DialogContent data-slot=\"admin-role-dialog\">\n <DialogHeader>\n <DialogTitle>Edit roles</DialogTitle>\n <DialogDescription>\n {principal?.kind === \"group\" ? (\n <>\n Every member of <strong>{principal.name ?? principal.id}</strong> inherits the roles\n you select here, as soon as they join.\n </>\n ) : (\n <>\n Direct assignment for <strong>{principal?.name ?? principal?.id}</strong>. Roles\n inherited from a group are changed on the group, not here.\n </>\n )}\n </DialogDescription>\n </DialogHeader>\n <DialogBody className=\"flex flex-col gap-2\">\n {error ? <ErrorBanner message={error} data-slot=\"admin-role-dialog-error\" /> : null}\n {options.length === 0 ? (\n <p className=\"text-[length:var(--ds-text-body-sm)] text-[color:var(--ds-muted-foreground)]\">\n This extension has not declared any roles yet.\n </p>\n ) : (\n <ul className=\"flex flex-col gap-0.5\" data-slot=\"admin-role-options\">\n {options.map((role) => {\n const checked = selected.includes(role);\n return (\n <li key={role}>\n <label\n className={cn(\n \"flex cursor-pointer items-center gap-2.5 rounded-[var(--ds-radius-md)] px-2 py-1.5\",\n \"text-[length:var(--ds-text-body-sm)] text-[color:var(--ds-foreground)]\",\n \"transition-colors duration-[var(--ds-duration-fast)] ease-[var(--ds-ease-out)]\",\n \"hover:bg-[var(--ds-hover)]\",\n )}\n >\n {/* No `aria-label`: the wrapping <label> already names\n the box, and Base UI points `aria-labelledby` at it.\n Both would compute the name as \"author author\". */}\n <Checkbox\n checked={checked}\n onCheckedChange={(next) => toggle(role, Boolean(next))}\n />\n <span className=\"flex-1\">{role}</span>\n {declared.includes(role) ? null : (\n <Chip size=\"sm\" variant=\"outline\" title=\"Not in the declared vocabulary\">\n retired\n </Chip>\n )}\n </label>\n </li>\n );\n })}\n </ul>\n )}\n </DialogBody>\n <DialogFooter>\n <Button variant=\"outline\" onClick={() => onOpenChange(false)} disabled={saving}>\n Cancel\n </Button>\n <Button onClick={save} disabled={saving || options.length === 0}>\n {saving ? <Spinner tone=\"inverse\" /> : null}\n Save roles\n </Button>\n </DialogFooter>\n </DialogContent>\n </Dialog>\n );\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
//#region src/components/admin-permissions/roles.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* `/admin/roles` — the extension's own vocabulary, and the group principals
|
|
6
|
+
* that hold it.
|
|
7
|
+
*
|
|
8
|
+
* Two halves. The first is what each declared role grants, read straight off
|
|
9
|
+
* the matrix, so an administrator deciding whether to hand somebody "editor"
|
|
10
|
+
* can see what "editor" means without leaving the screen. The second is group
|
|
11
|
+
* assignment: the other kind of principal a role attaches to (DESIGN-D22,
|
|
12
|
+
* P-42), on the same screen as the users because it is the same decision.
|
|
13
|
+
*
|
|
14
|
+
* The groups list is a **client** source, and the members list is not, for one
|
|
15
|
+
* reason: an org's groups are a short, bounded list that arrives in a single
|
|
16
|
+
* `groups.list()`, so paging it server-side would be a round-trip per keypress
|
|
17
|
+
* for nothing. Where the groups come from — a directory sync, or somebody
|
|
18
|
+
* typing them into cortena-auth — is invisible here by design.
|
|
19
|
+
*
|
|
20
|
+
* The whole section disappears when the api has no `groups`, and equally when
|
|
21
|
+
* it has some and the org has none.
|
|
22
|
+
*/
|
|
23
|
+
export declare function AdminRoles(): React.JSX.Element;
|
|
24
|
+
//#endregion
|
|
25
|
+
//# sourceMappingURL=roles.d.ts.map
|
|
@@ -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":"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"}
|
|
@@ -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"}
|