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.
Files changed (133) hide show
  1. package/LICENSE +7 -0
  2. package/README.md +149 -1
  3. package/dist/a2ui/views.js +2 -2
  4. package/dist/agent-chat/a2ui-block.d.ts +60 -0
  5. package/dist/agent-chat/a2ui-block.js +69 -0
  6. package/dist/agent-chat/a2ui-block.js.map +1 -0
  7. package/dist/agent-chat/agui-client.d.ts +40 -0
  8. package/dist/agent-chat/agui-client.js +251 -0
  9. package/dist/agent-chat/agui-client.js.map +1 -0
  10. package/dist/agent-chat/bridge.d.ts +109 -0
  11. package/dist/agent-chat/bridge.js +349 -0
  12. package/dist/agent-chat/bridge.js.map +1 -0
  13. package/dist/agent-chat/session.d.ts +58 -0
  14. package/dist/agent-chat/session.js +249 -0
  15. package/dist/agent-chat/session.js.map +1 -0
  16. package/dist/agent-chat/store.d.ts +67 -0
  17. package/dist/agent-chat/store.js +548 -0
  18. package/dist/agent-chat/store.js.map +1 -0
  19. package/dist/agent-chat/types.d.ts +187 -0
  20. package/dist/agent-chat/types.js +17 -0
  21. package/dist/agent-chat/types.js.map +1 -0
  22. package/dist/agent-chat.d.ts +10 -0
  23. package/dist/agent-chat.js +10 -0
  24. package/dist/components/admin-permissions/admin-permissions.d.ts +66 -0
  25. package/dist/components/admin-permissions/admin-permissions.js +101 -0
  26. package/dist/components/admin-permissions/admin-permissions.js.map +1 -0
  27. package/dist/components/admin-permissions/context.d.ts +70 -0
  28. package/dist/components/admin-permissions/context.js +258 -0
  29. package/dist/components/admin-permissions/context.js.map +1 -0
  30. package/dist/components/admin-permissions/index.d.ts +10 -0
  31. package/dist/components/admin-permissions/licence.d.ts +15 -0
  32. package/dist/components/admin-permissions/licence.js +78 -0
  33. package/dist/components/admin-permissions/licence.js.map +1 -0
  34. package/dist/components/admin-permissions/matrix.d.ts +20 -0
  35. package/dist/components/admin-permissions/matrix.js +191 -0
  36. package/dist/components/admin-permissions/matrix.js.map +1 -0
  37. package/dist/components/admin-permissions/members.d.ts +18 -0
  38. package/dist/components/admin-permissions/members.js +185 -0
  39. package/dist/components/admin-permissions/members.js.map +1 -0
  40. package/dist/components/admin-permissions/role-assignment.d.ts +35 -0
  41. package/dist/components/admin-permissions/role-assignment.js +174 -0
  42. package/dist/components/admin-permissions/role-assignment.js.map +1 -0
  43. package/dist/components/admin-permissions/roles.d.ts +25 -0
  44. package/dist/components/admin-permissions/roles.js +168 -0
  45. package/dist/components/admin-permissions/roles.js.map +1 -0
  46. package/dist/components/admin-permissions/types.d.ts +152 -0
  47. package/dist/components/admin-permissions/types.js +63 -0
  48. package/dist/components/admin-permissions/types.js.map +1 -0
  49. package/dist/components/agent-chat-popup.d.ts +29 -0
  50. package/dist/components/agent-chat-popup.js +188 -0
  51. package/dist/components/agent-chat-popup.js.map +1 -0
  52. package/dist/components/agent-chat.d.ts +143 -0
  53. package/dist/components/agent-chat.js +578 -0
  54. package/dist/components/agent-chat.js.map +1 -0
  55. package/dist/components/app-shell.d.ts +126 -0
  56. package/dist/components/app-shell.js +297 -0
  57. package/dist/components/app-shell.js.map +1 -0
  58. package/dist/components/badge.d.ts +1 -1
  59. package/dist/components/button-link.js +1 -1
  60. package/dist/components/button.d.ts +2 -2
  61. package/dist/components/checkbox.d.ts +1 -1
  62. package/dist/components/combobox.d.ts +1 -1
  63. package/dist/components/combobox.js +1 -1
  64. package/dist/components/consent-screen.d.ts +65 -0
  65. package/dist/components/consent-screen.js +123 -0
  66. package/dist/components/consent-screen.js.map +1 -0
  67. package/dist/components/data-table/data-table.d.ts +15 -1
  68. package/dist/components/data-table/data-table.js +18 -4
  69. package/dist/components/data-table/data-table.js.map +1 -1
  70. package/dist/components/data-table/index.d.ts +4 -4
  71. package/dist/components/data-table/parts.d.ts +27 -3
  72. package/dist/components/data-table/parts.js +175 -55
  73. package/dist/components/data-table/parts.js.map +1 -1
  74. package/dist/components/data-table/types.d.ts +61 -0
  75. package/dist/components/data-table/use-data-table.js +91 -6
  76. package/dist/components/data-table/use-data-table.js.map +1 -1
  77. package/dist/components/data-table/use-server-source.js +119 -28
  78. package/dist/components/data-table/use-server-source.js.map +1 -1
  79. package/dist/components/help-panel.d.ts +131 -0
  80. package/dist/components/help-panel.js +545 -0
  81. package/dist/components/help-panel.js.map +1 -0
  82. package/dist/components/login-screen.d.ts +127 -0
  83. package/dist/components/login-screen.js +339 -0
  84. package/dist/components/login-screen.js.map +1 -0
  85. package/dist/components/session-guard.d.ts +268 -0
  86. package/dist/components/session-guard.js +632 -0
  87. package/dist/components/session-guard.js.map +1 -0
  88. package/dist/components/toast.d.ts +1 -1
  89. package/dist/core.d.ts +5 -1
  90. package/dist/core.js +11 -7
  91. package/dist/data-table.d.ts +13 -4
  92. package/dist/data-table.js +10 -2
  93. package/dist/hooks/use-cortena-theme.js +49 -3
  94. package/dist/hooks/use-cortena-theme.js.map +1 -1
  95. package/dist/index.d.ts +17 -4
  96. package/dist/index.js +21 -8
  97. package/dist/markdown.d.ts +2 -1
  98. package/dist/markdown.js +2 -1
  99. package/package.json +16 -4
  100. package/src/agent-chat/a2ui-block.ts +118 -0
  101. package/src/agent-chat/agui-client.ts +405 -0
  102. package/src/agent-chat/bridge.ts +433 -0
  103. package/src/agent-chat/session.ts +392 -0
  104. package/src/agent-chat/store.ts +738 -0
  105. package/src/agent-chat/types.ts +213 -0
  106. package/src/components/admin-permissions/admin-permissions.tsx +130 -0
  107. package/src/components/admin-permissions/context.tsx +376 -0
  108. package/src/components/admin-permissions/index.tsx +32 -0
  109. package/src/components/admin-permissions/licence.tsx +84 -0
  110. package/src/components/admin-permissions/matrix.tsx +257 -0
  111. package/src/components/admin-permissions/members.tsx +204 -0
  112. package/src/components/admin-permissions/role-assignment.tsx +239 -0
  113. package/src/components/admin-permissions/roles.tsx +169 -0
  114. package/src/components/admin-permissions/types.ts +231 -0
  115. package/src/components/agent-chat-popup.tsx +289 -0
  116. package/src/components/agent-chat.tsx +843 -0
  117. package/src/components/app-shell.tsx +502 -0
  118. package/src/components/consent-screen.tsx +239 -0
  119. package/src/components/data-table/data-table.tsx +36 -0
  120. package/src/components/data-table/index.tsx +6 -1
  121. package/src/components/data-table/parts.tsx +223 -47
  122. package/src/components/data-table/types.ts +68 -0
  123. package/src/components/data-table/use-data-table.ts +152 -4
  124. package/src/components/data-table/use-server-source.ts +150 -12
  125. package/src/components/help-panel.tsx +765 -0
  126. package/src/components/login-screen.tsx +479 -0
  127. package/src/components/session-guard.tsx +1071 -0
  128. package/src/entries/agent-chat.ts +113 -0
  129. package/src/entries/core.ts +8 -0
  130. package/src/entries/data-table.ts +41 -0
  131. package/src/entries/markdown.ts +25 -0
  132. package/src/hooks/use-cortena-theme.ts +63 -4
  133. package/src/index.ts +6 -0
@@ -0,0 +1,239 @@
1
+ "use client";
2
+
3
+ import { Users } from "lucide-react";
4
+ import * as React from "react";
5
+ import { Button } from "@/components/button";
6
+ import { Checkbox } from "@/components/checkbox";
7
+ import { Chip } from "@/components/chip";
8
+ import {
9
+ Dialog,
10
+ DialogBody,
11
+ DialogContent,
12
+ DialogDescription,
13
+ DialogFooter,
14
+ DialogHeader,
15
+ DialogTitle,
16
+ } from "@/components/dialog";
17
+ import { ErrorBanner } from "@/components/error-banner";
18
+ import { Spinner } from "@/components/spinner";
19
+ import { cn } from "@/lib/cn";
20
+ import { useAdminPermissions } from "./context";
21
+ import {
22
+ adminErrorMessage,
23
+ sourceGroupOf,
24
+ type AdminEffectiveRole,
25
+ type AdminPrincipal,
26
+ } from "./types";
27
+
28
+ /**
29
+ * The effective-roles cell. Every role carries the assignment that granted it:
30
+ * nothing for a direct one, and the group's name for an inherited one. This is
31
+ * the whole point of §13.4 and P-43 — an administrator who cannot see why
32
+ * somebody has a permission removes the wrong assignment and believes the job
33
+ * is done.
34
+ */
35
+ export function EffectiveRoles({
36
+ roles,
37
+ className,
38
+ }: {
39
+ roles: readonly AdminEffectiveRole[];
40
+ className?: string;
41
+ }) {
42
+ if (roles.length === 0) {
43
+ return (
44
+ <span
45
+ data-slot="admin-effective-roles-empty"
46
+ className="text-[length:var(--ds-text-caption)] text-[color:var(--ds-text-tertiary)]"
47
+ >
48
+ No roles
49
+ </span>
50
+ );
51
+ }
52
+ return (
53
+ <div
54
+ data-slot="admin-effective-roles"
55
+ className={cn("flex flex-wrap items-center gap-1", className)}
56
+ >
57
+ {roles.map((entry, index) => {
58
+ const group = sourceGroupOf(entry.source);
59
+ return (
60
+ <Chip
61
+ key={`${entry.role}-${group?.id ?? "direct"}-${index}`}
62
+ size="sm"
63
+ variant={group ? "outline" : "accent"}
64
+ data-role={entry.role}
65
+ data-source={group ? group.id : "direct"}
66
+ title={
67
+ group
68
+ ? `${entry.role} — via group ${group.name}`
69
+ : `${entry.role} — assigned directly`
70
+ }
71
+ >
72
+ {entry.role}
73
+ {group ? (
74
+ <span className="inline-flex items-center gap-1 text-[color:var(--ds-text-tertiary)]">
75
+ <Users aria-hidden />
76
+ via {group.name}
77
+ </span>
78
+ ) : null}
79
+ </Chip>
80
+ );
81
+ })}
82
+ </div>
83
+ );
84
+ }
85
+
86
+ /** A plain list of directly assigned roles, for the group list. */
87
+ export function DirectRoles({ roles }: { roles: readonly string[] }) {
88
+ if (roles.length === 0) {
89
+ return (
90
+ <span
91
+ data-slot="admin-direct-roles-empty"
92
+ className="text-[length:var(--ds-text-caption)] text-[color:var(--ds-text-tertiary)]"
93
+ >
94
+ None
95
+ </span>
96
+ );
97
+ }
98
+ return (
99
+ <div data-slot="admin-direct-roles" className="flex flex-wrap items-center gap-1">
100
+ {roles.map((role) => (
101
+ <Chip key={role} size="sm" variant="accent" data-role={role}>
102
+ {role}
103
+ </Chip>
104
+ ))}
105
+ </div>
106
+ );
107
+ }
108
+
109
+ export interface RoleAssignmentDialogProps {
110
+ /** The principal being edited. A user and a group take the same dialog. */
111
+ principal: AdminPrincipal | null;
112
+ /** The principal's current direct assignment. */
113
+ current: readonly string[];
114
+ onOpenChange: (open: boolean) => void;
115
+ }
116
+
117
+ /**
118
+ * The one editor for a role assignment, for both principal kinds (DESIGN-D22).
119
+ * It offers the roles the extension declared and nothing else; a role already
120
+ * assigned that the matrix no longer declares is still listed, marked, and
121
+ * removable, because hiding it would leave a grant nobody can see or revoke.
122
+ */
123
+ export function RoleAssignmentDialog({
124
+ principal,
125
+ current,
126
+ onOpenChange,
127
+ }: RoleAssignmentDialogProps) {
128
+ const { matrix, assignRoles } = useAdminPermissions();
129
+ const [selected, setSelected] = React.useState<readonly string[]>(current);
130
+ const [saving, setSaving] = React.useState(false);
131
+ const [error, setError] = React.useState<string | null>(null);
132
+
133
+ // Re-seed whenever a different principal is opened — the documented
134
+ // adjust-state-during-render pattern, not an effect, so the dialog never
135
+ // paints one principal's roles under another principal's name.
136
+ const key = principal ? `${principal.kind}:${principal.id}` : null;
137
+ const [seededKey, setSeededKey] = React.useState<string | null>(key);
138
+ if (key !== seededKey) {
139
+ setSeededKey(key);
140
+ setSelected(current);
141
+ setError(null);
142
+ }
143
+
144
+ const declared = matrix?.roles ?? [];
145
+ const retired = current.filter((role) => !declared.includes(role));
146
+ const options = [...declared, ...retired];
147
+
148
+ const toggle = (role: string, checked: boolean) => {
149
+ setSelected((prev) => (checked ? [...prev, role] : prev.filter((r) => r !== role)));
150
+ };
151
+
152
+ const save = async () => {
153
+ if (!principal) return;
154
+ setSaving(true);
155
+ setError(null);
156
+ try {
157
+ await assignRoles(principal, [...selected]);
158
+ onOpenChange(false);
159
+ } catch (err) {
160
+ // The envelope's own message, not a house one (§15.6). The optimistic
161
+ // write has already been rolled back by `assignRoles`.
162
+ setError(adminErrorMessage(err));
163
+ } finally {
164
+ setSaving(false);
165
+ }
166
+ };
167
+
168
+ return (
169
+ <Dialog open={principal !== null} onOpenChange={(open) => !open && onOpenChange(false)}>
170
+ <DialogContent data-slot="admin-role-dialog">
171
+ <DialogHeader>
172
+ <DialogTitle>Edit roles</DialogTitle>
173
+ <DialogDescription>
174
+ {principal?.kind === "group" ? (
175
+ <>
176
+ Every member of <strong>{principal.name ?? principal.id}</strong> inherits the roles
177
+ you select here, as soon as they join.
178
+ </>
179
+ ) : (
180
+ <>
181
+ Direct assignment for <strong>{principal?.name ?? principal?.id}</strong>. Roles
182
+ inherited from a group are changed on the group, not here.
183
+ </>
184
+ )}
185
+ </DialogDescription>
186
+ </DialogHeader>
187
+ <DialogBody className="flex flex-col gap-2">
188
+ {error ? <ErrorBanner message={error} data-slot="admin-role-dialog-error" /> : null}
189
+ {options.length === 0 ? (
190
+ <p className="text-[length:var(--ds-text-body-sm)] text-[color:var(--ds-muted-foreground)]">
191
+ This extension has not declared any roles yet.
192
+ </p>
193
+ ) : (
194
+ <ul className="flex flex-col gap-0.5" data-slot="admin-role-options">
195
+ {options.map((role) => {
196
+ const checked = selected.includes(role);
197
+ return (
198
+ <li key={role}>
199
+ <label
200
+ className={cn(
201
+ "flex cursor-pointer items-center gap-2.5 rounded-[var(--ds-radius-md)] px-2 py-1.5",
202
+ "text-[length:var(--ds-text-body-sm)] text-[color:var(--ds-foreground)]",
203
+ "transition-colors duration-[var(--ds-duration-fast)] ease-[var(--ds-ease-out)]",
204
+ "hover:bg-[var(--ds-hover)]",
205
+ )}
206
+ >
207
+ {/* No `aria-label`: the wrapping <label> already names
208
+ the box, and Base UI points `aria-labelledby` at it.
209
+ Both would compute the name as "author author". */}
210
+ <Checkbox
211
+ checked={checked}
212
+ onCheckedChange={(next) => toggle(role, Boolean(next))}
213
+ />
214
+ <span className="flex-1">{role}</span>
215
+ {declared.includes(role) ? null : (
216
+ <Chip size="sm" variant="outline" title="Not in the declared vocabulary">
217
+ retired
218
+ </Chip>
219
+ )}
220
+ </label>
221
+ </li>
222
+ );
223
+ })}
224
+ </ul>
225
+ )}
226
+ </DialogBody>
227
+ <DialogFooter>
228
+ <Button variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>
229
+ Cancel
230
+ </Button>
231
+ <Button onClick={save} disabled={saving || options.length === 0}>
232
+ {saving ? <Spinner tone="inverse" /> : null}
233
+ Save roles
234
+ </Button>
235
+ </DialogFooter>
236
+ </DialogContent>
237
+ </Dialog>
238
+ );
239
+ }
@@ -0,0 +1,169 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { Button } from "@/components/button";
5
+ import { Chip } from "@/components/chip";
6
+ import { DataTable, createColumnHelper } from "@/components/data-table";
7
+ import { EmptyState } from "@/components/empty-state";
8
+ import { ErrorBanner } from "@/components/error-banner";
9
+ import { SectionCard } from "@/components/section-card";
10
+ import { useAdminPermissions } from "./context";
11
+ import { DirectRoles, RoleAssignmentDialog } from "./role-assignment";
12
+ import type { AdminGroupRow, AdminPrincipal } from "./types";
13
+
14
+ const helper = /* @__PURE__ */ createColumnHelper<AdminGroupRow>();
15
+
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
+ export function AdminRoles() {
36
+ const { matrix, hasGroups, groups, groupsState, groupRoles } = useAdminPermissions();
37
+ const [editing, setEditing] = React.useState<AdminPrincipal | null>(null);
38
+ const [editingRoles, setEditingRoles] = React.useState<readonly string[]>([]);
39
+
40
+ const columns = React.useMemo(
41
+ () =>
42
+ helper.columns([
43
+ helper.accessor("name", {
44
+ header: "Group",
45
+ cell: ({ row }) => (
46
+ <span className="font-medium text-[color:var(--ds-foreground)]">
47
+ {row.original.name}
48
+ </span>
49
+ ),
50
+ meta: { label: "Group" },
51
+ }),
52
+ helper.accessor("memberCount", {
53
+ header: "Members",
54
+ cell: ({ row }) => row.original.memberCount ?? "—",
55
+ meta: { align: "end", label: "Members" },
56
+ }),
57
+ helper.display({
58
+ id: "roles",
59
+ header: "Roles",
60
+ cell: ({ row }) => <DirectRoles roles={groupRoles[row.original.id] ?? []} />,
61
+ meta: {
62
+ label: "Roles",
63
+ exportValue: (row: AdminGroupRow) => (groupRoles[row.id] ?? []).join(", "),
64
+ },
65
+ enableSorting: false,
66
+ }),
67
+ helper.display({
68
+ id: "actions",
69
+ header: () => <span className="sr-only">Actions</span>,
70
+ cell: ({ row }) => (
71
+ <div className="flex justify-end">
72
+ <Button
73
+ variant="outline"
74
+ size="sm"
75
+ aria-label={`Edit roles for ${row.original.name}`}
76
+ onClick={() => {
77
+ setEditingRoles(groupRoles[row.original.id] ?? []);
78
+ setEditing({ kind: "group", id: row.original.id, name: row.original.name });
79
+ }}
80
+ >
81
+ Edit roles
82
+ </Button>
83
+ </div>
84
+ ),
85
+ meta: { align: "end", exportable: false },
86
+ enableSorting: false,
87
+ enableHiding: false,
88
+ }),
89
+ ]),
90
+ [groupRoles],
91
+ );
92
+
93
+ const roles = matrix?.roles ?? [];
94
+
95
+ return (
96
+ <div data-slot="admin-roles" className="flex flex-col gap-6">
97
+ <SectionCard
98
+ title="Roles"
99
+ description="Declared by this extension. Nothing on the platform reads these names, so they are the extension's to choose."
100
+ >
101
+ {roles.length === 0 ? (
102
+ <EmptyState
103
+ size="compact"
104
+ title="No roles declared"
105
+ description="Roles come from this extension's permissions document. Until it declares some, there is nothing to assign."
106
+ />
107
+ ) : (
108
+ <ul data-slot="admin-role-list" className="flex flex-col gap-2">
109
+ {roles.map((role) => {
110
+ const granted = matrix?.grants[role] ?? [];
111
+ return (
112
+ <li
113
+ key={role}
114
+ data-slot="admin-role-summary"
115
+ data-role={role}
116
+ 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"
117
+ >
118
+ <span className="text-[length:var(--ds-text-body-sm)] font-semibold text-[color:var(--ds-foreground)]">
119
+ {role}
120
+ </span>
121
+ {granted.length === 0 ? (
122
+ <span className="text-[length:var(--ds-text-caption)] text-[color:var(--ds-text-tertiary)]">
123
+ Grants nothing yet
124
+ </span>
125
+ ) : (
126
+ <div className="flex flex-wrap items-center gap-1">
127
+ {granted.map((action) => (
128
+ <Chip key={action} size="sm" variant="outline" data-action={action}>
129
+ {action}
130
+ </Chip>
131
+ ))}
132
+ </div>
133
+ )}
134
+ </li>
135
+ );
136
+ })}
137
+ </ul>
138
+ )}
139
+ </SectionCard>
140
+
141
+ {groupsState.error ? (
142
+ <ErrorBanner message={groupsState.error.message} onRetry={groupsState.reload} />
143
+ ) : null}
144
+
145
+ {hasGroups ? (
146
+ <SectionCard
147
+ title="Groups"
148
+ 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."
149
+ flush
150
+ >
151
+ <DataTable
152
+ columns={columns}
153
+ dataSource={{ kind: "client", rows: groups }}
154
+ getRowId={(row) => row.id}
155
+ searchPlaceholder="Search groups…"
156
+ emptyMessage="No groups"
157
+ exportFileName="groups"
158
+ />
159
+ </SectionCard>
160
+ ) : null}
161
+
162
+ <RoleAssignmentDialog
163
+ principal={editing}
164
+ current={editingRoles}
165
+ onOpenChange={(open) => !open && setEditing(null)}
166
+ />
167
+ </div>
168
+ );
169
+ }
@@ -0,0 +1,231 @@
1
+ /**
2
+ * The contract AdminPermissions renders, from §13.3 of
3
+ * "How to create a Cortena extension" (DESIGN-D11, amended by DESIGN-D22).
4
+ *
5
+ * The composition ships **no vocabulary of its own**. Every role, every action
6
+ * and every grant on screen arrives through the `api` prop; every principal
7
+ * arrives through it too. That is what lets two extensions with completely
8
+ * different role models look like one product — and it is why nothing in this
9
+ * folder has a default role list, a default action list, or an opinion about
10
+ * what "admin" means.
11
+ *
12
+ * The split the screen is built around:
13
+ *
14
+ * | cortena-auth owns | the extension owns |
15
+ * | --- | --- |
16
+ * | who the user is, their org membership and status | every role it recognises |
17
+ * | the groups, their names and their membership | the roles × actions matrix |
18
+ * | the licence | which principal holds which role |
19
+ *
20
+ * So the person half of the members list is read-only here, the group half is
21
+ * read-only too, and the roles beside them are the only editable thing.
22
+ *
23
+ * Where the groups come from is deliberately invisible: an extension asks
24
+ * `api.groups` for `{ id, name, memberCount }` and never learns whether that
25
+ * is a directory sync, an SCIM push or a hand-made list in cortena-auth.
26
+ */
27
+
28
+ import type { TableQuery, TableResult } from "@/components/data-table";
29
+
30
+ /**
31
+ * A role is assigned to a principal, never to a user id (DESIGN-D22, §13.4).
32
+ * Both kinds are edited on the same screen and through the same dialog; only
33
+ * the method underneath differs.
34
+ */
35
+ export interface AdminPrincipal {
36
+ kind: "user" | "group";
37
+ /** cortena-auth user id, or cortena-auth group id. */
38
+ id: string;
39
+ /** Display name, for the dialog title. */
40
+ name?: string;
41
+ }
42
+
43
+ /** A group, as a member's row refers to it. Defined and maintained in cortena-auth. */
44
+ export interface AdminGroupRef {
45
+ id: string;
46
+ name: string;
47
+ }
48
+
49
+ /**
50
+ * Why a member holds an effective role: assigned to them directly, or carried
51
+ * by a group they belong to. Never dropped from the rendering — an
52
+ * administrator who cannot see *why* somebody has a permission removes the
53
+ * wrong assignment and believes the job is done (P-43).
54
+ */
55
+ export type AdminRoleSource = "direct" | { group: AdminGroupRef };
56
+
57
+ /** One entry of `effectiveRoles`: the role, and the assignment that granted it. */
58
+ export interface AdminEffectiveRole {
59
+ role: string;
60
+ source: AdminRoleSource;
61
+ }
62
+
63
+ /**
64
+ * A row of `members.list`. A join: the person and their groups are
65
+ * cortena-auth's and read-only; `extensionRoles` is the extension's own direct
66
+ * assignment; `effectiveRoles` is the per-request resolution of direct +
67
+ * inherited that `may()` performs, rendered — never a stored table (P-43).
68
+ */
69
+ export interface AdminMemberRow {
70
+ userId: string;
71
+ name: string;
72
+ email: string;
73
+ status: string;
74
+ /** ISO timestamp, or null when the user has never signed in. */
75
+ lastActiveAt: string | null;
76
+ groups: AdminGroupRef[];
77
+ extensionRoles: string[];
78
+ effectiveRoles: AdminEffectiveRole[];
79
+ }
80
+
81
+ /**
82
+ * A row of `groups.list` — read-only as to the group itself. The roles a group
83
+ * carries are the extension's and come back separately from `groups.roles()`,
84
+ * because the group list is identity's and the assignment map is not.
85
+ */
86
+ export interface AdminGroupRow {
87
+ id: string;
88
+ name: string;
89
+ memberCount: number;
90
+ }
91
+
92
+ /** `licence.get` — a read-through display of cortena-auth's licence. */
93
+ export interface AdminLicence {
94
+ plan: string;
95
+ seats: number | null;
96
+ seatsUsed: number | null;
97
+ features: string[];
98
+ }
99
+
100
+ /**
101
+ * `permissions.get` / `permissions.put` — the extension's own matrix, in the
102
+ * extension's own database. This is the source of truth that both the REST
103
+ * routes and the MCP tools enforce through one `may()` (§13.2, P-14).
104
+ */
105
+ export interface AdminPermissionMatrix {
106
+ roles: string[];
107
+ actions: string[];
108
+ /** role → the actions it grants. A role absent from the map grants nothing. */
109
+ grants: Record<string, string[]>;
110
+ }
111
+
112
+ /**
113
+ * Everything the composition talks to, one namespace per resource in §13.3.
114
+ *
115
+ * `groups` is the only optional member, and it is optional for a reason rather
116
+ * than for convenience: an extension whose cortena-auth tenant has no groups
117
+ * omits it, and every group affordance disappears instead of rendering empty.
118
+ * It disappears equally when `groups.list()` comes back empty — an empty
119
+ * "Groups" section reads as a broken feature, and there is nothing an
120
+ * administrator can do about it from here.
121
+ */
122
+ export interface AdminPermissionsApi {
123
+ members: {
124
+ /** `GET .../admin/members` — the DataTable server source. */
125
+ list: (query: TableQuery, signal?: AbortSignal) => Promise<TableResult<AdminMemberRow>>;
126
+ /** `PATCH .../admin/members/:userId { extensionRoles }` — direct assignment only. */
127
+ setRoles: (userId: string, roles: string[]) => Promise<void>;
128
+ };
129
+ groups?: {
130
+ /** `GET .../admin/groups` — read-only; the whole list, small enough to be a client source. */
131
+ list: (signal?: AbortSignal) => Promise<AdminGroupRow[]>;
132
+ /** The extension's assignment: group id → the roles that group carries. */
133
+ roles: (signal?: AbortSignal) => Promise<Record<string, string[]>>;
134
+ /** `PATCH .../admin/groups/:groupId { extensionRoles }` */
135
+ setRoles: (groupId: string, roles: string[]) => Promise<void>;
136
+ };
137
+ licence: {
138
+ /** `GET .../admin/licence` — read-only, always. */
139
+ get: (signal?: AbortSignal) => Promise<AdminLicence>;
140
+ };
141
+ permissions: {
142
+ /** `GET .../admin/permissions` */
143
+ get: (signal?: AbortSignal) => Promise<AdminPermissionMatrix>;
144
+ /** `PUT .../admin/permissions` */
145
+ put: (
146
+ matrix: AdminPermissionMatrix,
147
+ signal?: AbortSignal,
148
+ ) => Promise<AdminPermissionMatrix | void>;
149
+ };
150
+ }
151
+
152
+ /** The four screens, named after their paths under `admin/`. */
153
+ export type AdminRoute = "members" | "roles" | "licence" | "permissions";
154
+
155
+ export const ADMIN_ROUTES: readonly AdminRoute[] = [
156
+ "members",
157
+ "roles",
158
+ "licence",
159
+ "permissions",
160
+ ];
161
+
162
+ export const ADMIN_ROUTE_LABELS: Record<AdminRoute, string> = {
163
+ members: "Members",
164
+ roles: "Roles",
165
+ licence: "Licence",
166
+ permissions: "Permissions",
167
+ };
168
+
169
+ /**
170
+ * The broker's error envelope (§15.6). A failed mutation must show what the
171
+ * server said — "assigneeId: Required" and "Invalid input" cost the same to
172
+ * produce and differ by a whole round-trip — so the rejection is unwrapped
173
+ * rather than replaced with a house message.
174
+ */
175
+ export interface AdminErrorEnvelope {
176
+ ok: false;
177
+ status?: number;
178
+ code?: string;
179
+ message: string;
180
+ details?: unknown;
181
+ }
182
+
183
+ function envelopeMessage(value: unknown): string | null {
184
+ if (!value || typeof value !== "object") return null;
185
+ const message = (value as { message?: unknown }).message;
186
+ return typeof message === "string" && message.length > 0 ? message : null;
187
+ }
188
+
189
+ /**
190
+ * The message to show for a rejection.
191
+ *
192
+ * A fetch wrapper usually rejects with an `Error` and hangs the parsed
193
+ * envelope off it, so the envelope is preferred over the generic
194
+ * `"HTTP 403"` the Error itself carries. A rejection that *is* the envelope,
195
+ * and a plain `Error`, both work as well.
196
+ */
197
+ export function adminErrorMessage(error: unknown): string {
198
+ if (error && typeof error === "object") {
199
+ const carrier = error as { envelope?: unknown; body?: unknown; error?: unknown };
200
+ const nested =
201
+ envelopeMessage(carrier.envelope) ??
202
+ envelopeMessage(carrier.body) ??
203
+ envelopeMessage(carrier.error);
204
+ if (nested) return nested;
205
+ }
206
+ if (error instanceof Error) return error.message || "Something went wrong";
207
+ const direct = envelopeMessage(error);
208
+ if (direct) return direct;
209
+ return String(error);
210
+ }
211
+
212
+ /** Tolerates a server that omits an array the contract says is required. */
213
+ export function groupsOf(row: AdminMemberRow): AdminGroupRef[] {
214
+ return row.groups ?? [];
215
+ }
216
+
217
+ /**
218
+ * The effective roles to render. When the server sends none, the direct
219
+ * assignments are shown as direct — which is what they are — rather than the
220
+ * screen inventing an inheritance it cannot know about.
221
+ */
222
+ export function effectiveRolesOf(row: AdminMemberRow): AdminEffectiveRole[] {
223
+ if (row.effectiveRoles?.length) return row.effectiveRoles;
224
+ return (row.extensionRoles ?? []).map((role) => ({ role, source: "direct" as const }));
225
+ }
226
+
227
+ /** The group behind an effective role, or `null` for a direct assignment. */
228
+ export function sourceGroupOf(source: AdminRoleSource): AdminGroupRef | null {
229
+ if (source === "direct" || !source) return null;
230
+ return source.group ?? null;
231
+ }